Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 13 additions & 7 deletions src/main/java/gg/agit/konect/domain/user/controller/UserApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@
import org.springframework.web.bind.annotation.RequestMapping;

import gg.agit.konect.domain.user.dto.SignupRequest;
import gg.agit.konect.domain.user.dto.UserAccessTokenResponse;
import gg.agit.konect.domain.user.dto.UserInfoResponse;
import gg.agit.konect.domain.user.dto.UserUpdateRequest;
import gg.agit.konect.global.auth.annotation.PublicApi;
import gg.agit.konect.global.auth.annotation.UserId;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;

@Tag(name = "(Normal) User: 유저", description = "유저 API")
Expand All @@ -38,9 +39,9 @@ public interface UserApi {
@PostMapping("/signup")
@PublicApi
ResponseEntity<Void> signup(
HttpServletRequest httpServletRequest,
HttpSession session,
@RequestBody @Valid SignupRequest request
HttpServletRequest request,
HttpServletResponse response,
@RequestBody @Valid SignupRequest signupRequest
);

@Operation(summary = "로그인한 사용자의 정보를 조회한다.")
Expand All @@ -50,16 +51,21 @@ ResponseEntity<Void> signup(
@Operation(summary = "로그인한 사용자의 정보를 수정한다.")
@PutMapping("/me")
ResponseEntity<Void> updateMyInfo(
HttpSession session,
@UserId Integer userId,
@RequestBody @Valid UserUpdateRequest request
);

@Operation(summary = "로그아웃한다.")
@PostMapping("/logout")
@PublicApi
ResponseEntity<Void> logout(HttpServletRequest request);
ResponseEntity<Void> logout(HttpServletRequest request, HttpServletResponse response);

@Operation(summary = "리프레시 토큰으로 액세스 토큰을 재발급한다.")
@PostMapping("/refresh")
@PublicApi
ResponseEntity<UserAccessTokenResponse> refresh(HttpServletRequest request, HttpServletResponse response);

@Operation(summary = "회원탈퇴를 한다.")
@DeleteMapping("/withdraw")
ResponseEntity<Void> withdraw(HttpServletRequest request, @UserId Integer userId);
ResponseEntity<Void> withdraw(HttpServletRequest request, HttpServletResponse response, @UserId Integer userId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,23 @@
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.util.WebUtils;

import gg.agit.konect.domain.user.dto.SignupRequest;
import gg.agit.konect.domain.user.dto.UserAccessTokenResponse;
import gg.agit.konect.domain.user.dto.UserInfoResponse;
import gg.agit.konect.domain.user.dto.UserUpdateRequest;
import gg.agit.konect.domain.user.enums.Provider;
import gg.agit.konect.domain.user.service.UserService;
import gg.agit.konect.global.auth.annotation.PublicApi;
import gg.agit.konect.global.auth.annotation.UserId;
import gg.agit.konect.global.code.ApiResponseCode;
import gg.agit.konect.global.exception.CustomException;
import gg.agit.konect.global.auth.AccessTokenBlacklistService;
import gg.agit.konect.global.auth.JwtProvider;
import gg.agit.konect.global.auth.token.AuthCookieService;
import gg.agit.konect.domain.user.service.RefreshTokenService;
import gg.agit.konect.domain.user.service.SignupTokenService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;

Expand All @@ -23,28 +29,35 @@
@RequestMapping("/users")
public class UserController implements UserApi {

private static final String AUTHORIZATION_HEADER = "Authorization";
private static final String BEARER_PREFIX = "Bearer ";

private final UserService userService;
private final SignupTokenService signupTokenService;
private final JwtProvider jwtProvider;
private final RefreshTokenService refreshTokenService;
private final AuthCookieService authCookieService;
private final AccessTokenBlacklistService accessTokenBlacklistService;

@Override
@PublicApi
public ResponseEntity<Void> signup(
HttpServletRequest httpServletRequest,
HttpSession session,
@RequestBody @Valid SignupRequest request
HttpServletRequest request,
HttpServletResponse response,
@RequestBody @Valid SignupRequest signupRequest
) {
String email = (String)session.getAttribute("email");
Provider provider = (Provider)session.getAttribute("provider");
String providerId = (String)session.getAttribute("providerId");
String signupToken = getCookieValue(request, AuthCookieService.SIGNUP_TOKEN_COOKIE);
SignupTokenService.SignupClaims claims = signupTokenService.consumeOrThrow(signupToken);

if (email == null || provider == null) {
throw CustomException.of(ApiResponseCode.INVALID_SESSION);
}
Integer userId = userService.signup(claims.email(), claims.providerId(), claims.provider(), signupRequest);

Integer userId = userService.signup(email, providerId, provider, request);
authCookieService.clearSignupToken(request, response);

session.invalidate();
String refreshToken = refreshTokenService.issue(userId);
authCookieService.setRefreshToken(request, response, refreshToken, refreshTokenService.refreshTtl());

HttpSession newSession = httpServletRequest.getSession(true);
newSession.setAttribute("userId", userId);
String accessToken = jwtProvider.createToken(userId);
response.setHeader("Authorization", "Bearer " + accessToken);

return ResponseEntity.ok().build();
}
Expand All @@ -58,32 +71,66 @@ public ResponseEntity<UserInfoResponse> getMyInfo(@UserId Integer userId) {

@Override
public ResponseEntity<Void> updateMyInfo(
HttpSession session,
@UserId Integer userId,
@RequestBody @Valid UserUpdateRequest request
) {
Integer userId = (Integer)session.getAttribute("userId");

userService.updateUserInfo(userId, request);

return ResponseEntity.ok().build();
}

@Override
public ResponseEntity<Void> logout(HttpServletRequest request) {
HttpSession session = request.getSession(false);
@PublicApi
public ResponseEntity<Void> logout(HttpServletRequest request, HttpServletResponse response) {
String accessToken = resolveBearerToken(request);
accessTokenBlacklistService.blacklist(accessToken);

if (session != null) {
session.invalidate();
}
String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE);
refreshTokenService.revoke(refreshToken);

authCookieService.clearRefreshToken(request, response);
authCookieService.clearSignupToken(request, response);

return ResponseEntity.ok().build();
}

@Override
public ResponseEntity<Void> withdraw(HttpServletRequest request, @UserId Integer userId) {
@PublicApi
public ResponseEntity<UserAccessTokenResponse> refresh(HttpServletRequest request, HttpServletResponse response) {
String oldAccessToken = resolveBearerToken(request);
accessTokenBlacklistService.blacklist(oldAccessToken);

String refreshToken = getCookieValue(request, AuthCookieService.REFRESH_TOKEN_COOKIE);
RefreshTokenService.Rotated rotated = refreshTokenService.rotate(refreshToken);

String accessToken = jwtProvider.createToken(rotated.userId());
authCookieService.setRefreshToken(request, response, rotated.refreshToken(), refreshTokenService.refreshTtl());

return ResponseEntity.ok(new UserAccessTokenResponse(accessToken));
}

private String resolveBearerToken(HttpServletRequest request) {
String authorization = request.getHeader(AUTHORIZATION_HEADER);
if (authorization == null || !authorization.startsWith(BEARER_PREFIX)) {
return null;
}
return authorization.substring(BEARER_PREFIX.length());
}

@Override
public ResponseEntity<Void> withdraw(
HttpServletRequest request,
HttpServletResponse response,
@UserId Integer userId
) {
userService.deleteUser(userId);
logout(request);
logout(request, response);

return ResponseEntity.noContent().build();
}

private String getCookieValue(HttpServletRequest request, String name) {
Cookie cookie = WebUtils.getCookie(request, name);
return cookie == null ? null : cookie.getValue();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package gg.agit.konect.domain.user.dto;

import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;

import io.swagger.v3.oas.annotations.media.Schema;

public record UserAccessTokenResponse(
@Schema(
description = "액세스 토큰",
example = "eyJhbGciOiJIUzI1NiJ9...",
requiredMode = REQUIRED
)
String accessToken
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package gg.agit.konect.domain.user.service;

import java.security.SecureRandom;
import java.time.Duration;
import java.util.Base64;
import java.util.List;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

import gg.agit.konect.global.code.ApiResponseCode;
import gg.agit.konect.global.exception.CustomException;
import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class RefreshTokenService {

private static final int TOKEN_BYTES = 32;

private static final Duration REFRESH_TOKEN_TTL = Duration.ofDays(30);

private static final String ACTIVE_PREFIX = "auth:refresh:active:";
private static final String REVOKED_PREFIX = "auth:refresh:revoked:";
private static final String USER_SET_PREFIX = "auth:refresh:user:";

private static final DefaultRedisScript<String> GET_DEL_SCRIPT =
new DefaultRedisScript<>(
"local v = redis.call('GET', KEYS[1]); " +
"if v then redis.call('DEL', KEYS[1]); end; " +
"return v;",
String.class
);

private final SecureRandom secureRandom = new SecureRandom();

private final StringRedisTemplate redis;

public Duration refreshTtl() {
return REFRESH_TOKEN_TTL;
}

public String issue(Integer userId) {
if (userId == null) {
throw new IllegalArgumentException("userId is required");
}

String token = generateToken();
Duration ttl = refreshTtl();

redis.opsForValue().set(activeKey(token), userId.toString(), ttl);
redis.opsForSet().add(userSetKey(userId), token);
redis.expire(userSetKey(userId), ttl);

return token;
}

public Rotated rotate(String refreshToken) {
if (!StringUtils.hasText(refreshToken)) {
throw CustomException.of(ApiResponseCode.INVALID_SESSION);
}

Integer userId = consumeActive(refreshToken);
if (userId == null) {
Integer revokedUserId = findRevokedUserId(refreshToken);
if (revokedUserId != null) {
revokeAll(revokedUserId);
}
throw CustomException.of(ApiResponseCode.INVALID_SESSION);
}

String newToken = issue(userId);
return new Rotated(userId, newToken);
}

public void revoke(String refreshToken) {
if (!StringUtils.hasText(refreshToken)) {
return;
}

String value = redis.execute(GET_DEL_SCRIPT, List.of(activeKey(refreshToken)));
Integer userId = parseUserId(value);
if (userId == null) {
return;
}

Duration ttl = refreshTtl();
redis.opsForValue().set(revokedKey(refreshToken), userId.toString(), ttl);
redis.opsForSet().remove(userSetKey(userId), refreshToken);
}

public void revokeAll(Integer userId) {
if (userId == null) {
return;
}

String setKey = userSetKey(userId);
var tokens = redis.opsForSet().members(setKey);

if (tokens == null || tokens.isEmpty()) {
redis.delete(setKey);
return;
}

Duration ttl = refreshTtl();
for (String token : tokens) {
redis.delete(activeKey(token));
redis.opsForValue().set(revokedKey(token), userId.toString(), ttl);
}

redis.delete(setKey);
}

private Integer consumeActive(String token) {
String value = redis.execute(GET_DEL_SCRIPT, List.of(activeKey(token)));
Integer userId = parseUserId(value);
if (userId == null) {
return null;
}

Duration ttl = refreshTtl();
redis.opsForValue().set(revokedKey(token), userId.toString(), ttl);
redis.opsForSet().remove(userSetKey(userId), token);
return userId;
}

private Integer findRevokedUserId(String token) {
String value = redis.opsForValue().get(revokedKey(token));
return parseUserId(value);
}

private String activeKey(String token) {
return ACTIVE_PREFIX + token;
}

private String revokedKey(String token) {
return REVOKED_PREFIX + token;
}

private String userSetKey(Integer userId) {
return USER_SET_PREFIX + userId;
}

private String generateToken() {
byte[] bytes = new byte[TOKEN_BYTES];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

private Integer parseUserId(String value) {
if (!StringUtils.hasText(value)) {
return null;
}

try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
return null;
}
}

public record Rotated(Integer userId, String refreshToken) {
}
}
Loading