Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] 로그아웃 기능 추가 #151

Merged
merged 16 commits into from
Aug 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
209d12a
refactor(jwtManager): jwt 토큰에 만료 시간을 추가
seokmyungham Aug 2, 2024
6e4e331
refactor: 로그인 시 참가자의 이름을 같이 반환하도록 변경
seokmyungham Aug 2, 2024
93229ee
feat: path 경로 수정 및 로그아웃 api 추가
seokmyungham Aug 2, 2024
9eb67ae
chore: 토큰 만료시간 추가
seokmyungham Aug 2, 2024
b99e92b
chore: 서브모듈 업데이트 반영
seokmyungham Aug 2, 2024
b4c10a9
refactor(JwtCookieManager): 쿠키 생성의 책임을 컨트롤러에서 분리
seokmyungham Aug 2, 2024
599990b
refactor(MeetingController): 약속 최초 생성시 주최자에 대한 JWT 토큰을 쿠키로 전송하도록 변경
seokmyungham Aug 2, 2024
ece6223
style(JwtManager): 생성자 매개변수 팀 컨벤션 개행 적용
seokmyungham Aug 3, 2024
267f41a
refactor: 쿠키 관련 상수 명 설정
seokmyungham Aug 3, 2024
ed7f6a9
refactor(JwtCookieManager): 클래스 이름을 CookieManager 로 변경
seokmyungham Aug 3, 2024
0c615d6
refactor(CookieManager): SAME SITE 상수 명을 변경하여 의미를 개선
seokmyungham Aug 3, 2024
7b56899
refactor(JwtProperties): JWT 관련 환경 변수들을 POJO로 관리하도록 개선
seokmyungham Aug 3, 2024
8cdb967
chore: 서브모듈 업데이트 반영
seokmyungham Aug 3, 2024
02b07bd
refactor(JwtProperties): expirationPeriod 필드 NotNull 검증 추가
seokmyungham Aug 3, 2024
f9d31b2
refactor: ConfigurationPropertiesScan을 사용하여 전역으로 관리하도록 변경
seokmyungham Aug 3, 2024
b1a6fce
refactor(CookieManager): 클래스의 책임에 맞도록 상수 및 변수 이동
seokmyungham Aug 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/main/java/kr/momo/MomoApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@ConfigurationPropertiesScan
seokmyungham marked this conversation as resolved.
Show resolved Hide resolved
@SpringBootApplication
public class MomoApplication {

Expand Down
32 changes: 32 additions & 0 deletions backend/src/main/java/kr/momo/controller/CookieManager.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package kr.momo.controller;

import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;

@Component
public class CookieManager {

private static final String ACCESS_TOKEN = "ACCESS_TOKEN";
private static final String SAME_SITE_OPTION = "None";
private static final long SESSION_COOKIE_AGE = -1;
private static final long EXPIRED_COOKIE_AGE = 0;

public String createNewCookie(String value, String path) {
return createCookie(value, path, SESSION_COOKIE_AGE);
}

public String createExpiredCookie(String path) {
return createCookie("", path, EXPIRED_COOKIE_AGE);
}

private String createCookie(String value, String path, long maxAge) {
return ResponseCookie.from(ACCESS_TOKEN, value)
.httpOnly(true)
.secure(true)
.path(path)
.sameSite(SAME_SITE_OPTION)
.maxAge(maxAge)
.build()
.toString();
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package kr.momo.controller.attendee;

import jakarta.validation.Valid;
import kr.momo.controller.CookieManager;
import kr.momo.controller.MomoApiResponse;
import kr.momo.service.attendee.AttendeeService;
import kr.momo.service.attendee.dto.AttendeeLoginRequest;
import kr.momo.service.attendee.dto.AttendeeLoginResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -16,28 +18,28 @@
@RequiredArgsConstructor
public class AttendeeController {

private static final String ACCESS_TOKEN = "ACCESS_TOKEN";
private static final String SAME_SITE_SETTING = "None";

private final AttendeeService attendeeService;
private final CookieManager cookieManager;

@PostMapping("/api/v1/meetings/{uuid}/login")
public ResponseEntity<Void> login(@PathVariable String uuid, @RequestBody @Valid AttendeeLoginRequest request) {
String token = attendeeService.login(uuid, request);
String path = String.format("/api/v1/meetings/%s/", uuid);
public ResponseEntity<MomoApiResponse<String>> login(
@PathVariable String uuid, @RequestBody @Valid AttendeeLoginRequest request
) {
AttendeeLoginResponse response = attendeeService.login(uuid, request);
String path = String.format("/meeting/%s", uuid);
String cookie = cookieManager.createNewCookie(response.token(), path);

return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, createCookie(token, path))
.build();
.header(HttpHeaders.SET_COOKIE, cookie)
.body(new MomoApiResponse<>(response.name()));
}

private String createCookie(String value, String path) {
return ResponseCookie.from(ACCESS_TOKEN, value)
.httpOnly(true)
.secure(true)
.path(path)
.sameSite(SAME_SITE_SETTING)
.build()
.toString();
@PostMapping("/api/v1/meetings/{uuid}/logout")
public ResponseEntity<Void> logout(@PathVariable String uuid) {
String cookie = cookieManager.createExpiredCookie(uuid);

return ResponseEntity.ok()
.header(HttpHeaders.SET_COOKIE, cookie)
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import jakarta.validation.Valid;
import java.net.URI;
import kr.momo.controller.CookieManager;
import kr.momo.controller.MomoApiResponse;
import kr.momo.controller.auth.AuthAttendee;
import kr.momo.service.meeting.MeetingService;
import kr.momo.service.meeting.dto.MeetingCreateRequest;
import kr.momo.service.meeting.dto.MeetingCreateResponse;
import kr.momo.service.meeting.dto.MeetingResponse;
import kr.momo.service.meeting.dto.MeetingSharingResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
Expand All @@ -22,13 +25,18 @@
public class MeetingController {

private final MeetingService meetingService;
private final CookieManager cookieManager;

@PostMapping("/api/v1/meetings")
public ResponseEntity<MomoApiResponse<MeetingSharingResponse>> create(
public ResponseEntity<MomoApiResponse<MeetingCreateResponse>> create(
@RequestBody @Valid MeetingCreateRequest request
) {
MeetingSharingResponse response = meetingService.create(request);
MeetingCreateResponse response = meetingService.create(request);
String path = String.format("/meeting/%s", response.uuid());
String cookie = cookieManager.createNewCookie(response.token(), path);

return ResponseEntity.created(URI.create("/meeting/" + response.uuid()))
.header(HttpHeaders.SET_COOKIE, cookie)
.body(new MomoApiResponse<>(response));
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@

public enum AuthErrorCode implements ErrorCodeType {

INVALID_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 토큰입니다."),
NOT_FOUND_TOKEN(HttpStatus.UNAUTHORIZED, "토큰이 존재하지 않습니다.");
UNAUTHORIZED_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 토큰입니다.");
seokmyungham marked this conversation as resolved.
Show resolved Hide resolved

private final HttpStatus httpStatus;
private final String message;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import kr.momo.exception.MomoException;
import kr.momo.exception.code.MeetingErrorCode;
import kr.momo.service.attendee.dto.AttendeeLoginRequest;
import kr.momo.service.attendee.dto.AttendeeLoginResponse;
import kr.momo.service.auth.JwtManager;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
Expand All @@ -24,7 +25,7 @@ public class AttendeeService {
private final JwtManager jwtManager;

@Transactional
public String login(String uuid, AttendeeLoginRequest request) {
public AttendeeLoginResponse login(String uuid, AttendeeLoginRequest request) {
Meeting meeting = meetingRepository.findByUuid(uuid)
.orElseThrow(() -> new MomoException(MeetingErrorCode.INVALID_UUID));

Expand All @@ -36,14 +37,14 @@ public String login(String uuid, AttendeeLoginRequest request) {
.orElseGet(() -> signup(meeting, name, password));
}

private String verifyPassword(Attendee attendee, AttendeePassword password) {
private AttendeeLoginResponse verifyPassword(Attendee attendee, AttendeePassword password) {
attendee.verifyPassword(password);
return jwtManager.generate(attendee.getId());
return AttendeeLoginResponse.from(jwtManager.generate(attendee.getId()), attendee);
}

private String signup(Meeting meeting, AttendeeName name, AttendeePassword password) {
private AttendeeLoginResponse signup(Meeting meeting, AttendeeName name, AttendeePassword password) {
Attendee attendee = new Attendee(meeting, name, password, Role.GUEST);
attendeeRepository.save(attendee);
return jwtManager.generate(attendee.getId());
return AttendeeLoginResponse.from(jwtManager.generate(attendee.getId()), attendee);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.momo.service.attendee.dto;

import kr.momo.domain.attendee.Attendee;

public record AttendeeLoginResponse(String token, String name) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재는 이름만 내려주고 있기는 한데, 사용자의 HOST 여부도 필요할지 프론트 측과 이야기 해 봐야 할 것 같아요

(명시적인 사용자 권한을 응답으로 내려도 되는 건지는 잘 모르겠네요🥲 어떻게 생각하시나요?)

Copy link
Contributor Author

@seokmyungham seokmyungham Aug 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음.. 정말 HOST, GUEST 여부에 따라 사용자에게 화면을 다르게 보여줘야 한다면, 해당 값을 응답으로 내려주어야 할 것 같네요.

그런데 사용자 권한이기는 하지만 현재 약속 만들고 확정하는 일이
게시글을 작성하고 삭제하거나 투표를 만들고 종료하는 상황과 비슷하다는 느낌이 들긴 해요. 🤔
엄밀하게 검토해보고 비슷한 상황에 대한 레퍼런스를 찾는 건 어렵지 않으니 한 번 적용해볼 수 있을 것 같아요 😀


public static AttendeeLoginResponse from(String token, Attendee attendee) {
return new AttendeeLoginResponse(token, attendee.name());
}
}
24 changes: 12 additions & 12 deletions backend/src/main/java/kr/momo/service/auth/JwtManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,28 @@
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import java.util.Date;
import kr.momo.exception.MomoException;
import kr.momo.exception.code.AuthErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtManager {

private static final String CLAIM_ID = "id";

private final String secretKey;

public JwtManager(@Value("${security.jwt.secret_key}") String secretKey) {
this.secretKey = secretKey;
}
private final JwtProperties jwtProperties;

public String generate(long id) {
Date expirationDate = new Date(System.currentTimeMillis() + jwtProperties.getExpirationPeriodInMillis());
return JWT.create()
.withClaim(CLAIM_ID, id)
.sign(Algorithm.HMAC256(secretKey));
.withExpiresAt(expirationDate)
.sign(Algorithm.HMAC256(jwtProperties.getSecretKey()));
}

public long extract(String token) {
Expand All @@ -39,21 +39,21 @@ public long extract(String token) {

private DecodedJWT verifyToken(String token) {
try {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
Algorithm algorithm = Algorithm.HMAC256(jwtProperties.getSecretKey());
JWTVerifier verifier = JWT.require(algorithm).build();
return verifier.verify(token);
} catch (InvalidClaimException e) {
log.warn("토큰 내 클레임이 유효하지 않습니다. {} ", e.getMessage());
throw new MomoException(AuthErrorCode.INVALID_TOKEN);
throw new MomoException(AuthErrorCode.UNAUTHORIZED_TOKEN);
} catch (JWTDecodeException e) {
log.warn("JWT 토큰 구성이 올바르지 않습니다. {} ", e.getMessage());
throw new MomoException(AuthErrorCode.INVALID_TOKEN);
throw new MomoException(AuthErrorCode.UNAUTHORIZED_TOKEN);
} catch (SignatureVerificationException e) {
log.warn("토큰의 서명이 알고리즘을 사용하여 검증했을 때 유효하지 않습니다. {} ", e.getMessage());
throw new MomoException(AuthErrorCode.INVALID_TOKEN);
throw new MomoException(AuthErrorCode.UNAUTHORIZED_TOKEN);
} catch (JWTVerificationException e) {
log.warn("토큰 검증 과정에서 예기치 못한 에러가 발생하였습니다. {} ", e.getMessage());
throw new MomoException(AuthErrorCode.INVALID_TOKEN);
throw new MomoException(AuthErrorCode.UNAUTHORIZED_TOKEN);
}
}
}
28 changes: 28 additions & 0 deletions backend/src/main/java/kr/momo/service/auth/JwtProperties.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package kr.momo.service.auth;

import jakarta.validation.constraints.NotNull;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.convert.DurationUnit;
import org.springframework.validation.annotation.Validated;

@Getter
@Validated
@RequiredArgsConstructor
@ConfigurationProperties("security.jwt")
public class JwtProperties {

@NotNull
private final String secretKey;

@NotNull
@DurationUnit(ChronoUnit.HOURS)
private final Duration expirationPeriod;

public long getExpirationPeriodInMillis() {
return getExpirationPeriod().toMillis();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
import kr.momo.exception.MomoException;
import kr.momo.exception.code.AttendeeErrorCode;
import kr.momo.exception.code.MeetingErrorCode;
import kr.momo.service.auth.JwtManager;
import kr.momo.service.meeting.dto.MeetingCreateRequest;
import kr.momo.service.meeting.dto.MeetingCreateResponse;
import kr.momo.service.meeting.dto.MeetingResponse;
import kr.momo.service.meeting.dto.MeetingSharingResponse;
import lombok.RequiredArgsConstructor;
Expand All @@ -28,13 +30,17 @@ public class MeetingService {
private final MeetingRepository meetingRepository;
private final AvailableDateRepository availableDateRepository;
private final AttendeeRepository attendeeRepository;
private final JwtManager jwtManager;

@Transactional
public MeetingSharingResponse create(MeetingCreateRequest request) {
public MeetingCreateResponse create(MeetingCreateRequest request) {
Meeting meeting = saveMeeting(request.meetingName(), request.meetingStartTime(), request.meetingEndTime());
saveAvailableDates(request.meetingAvailableDates(), meeting);
saveHostAttendee(meeting, request.hostName(), request.hostPassword());
return MeetingSharingResponse.from(meeting);

Attendee attendee = saveHostAttendee(meeting, request.hostName(), request.hostPassword());
String token = jwtManager.generate(attendee.getId());

return MeetingCreateResponse.from(meeting, attendee, token);
}

private Meeting saveMeeting(String meetingName, LocalTime startTime, LocalTime endTime) {
Expand All @@ -47,9 +53,9 @@ private void saveAvailableDates(List<LocalDate> dates, Meeting meeting) {
availableDateRepository.saveAll(availableDates.getAvailableDates());
}

private void saveHostAttendee(Meeting meeting, String hostName, String hostPassword) {
private Attendee saveHostAttendee(Meeting meeting, String hostName, String hostPassword) {
Attendee attendee = new Attendee(meeting, hostName, hostPassword, Role.HOST);
attendeeRepository.save(attendee);
return attendeeRepository.save(attendee);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.momo.service.meeting.dto;

import kr.momo.domain.attendee.Attendee;
import kr.momo.domain.meeting.Meeting;

public record MeetingCreateResponse(String uuid, String name, String token) {

public static MeetingCreateResponse from(Meeting meeting, Attendee attendee, String token) {
return new MeetingCreateResponse(meeting.getUuid(), attendee.name(), token);
}
}
3 changes: 2 additions & 1 deletion backend/src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,5 @@ spring:

security:
jwt:
secret_key: ${random.value}"
secret-key: ${random.value}"
expiration-period: 1h
2 changes: 1 addition & 1 deletion backend/src/main/resources/security
Loading