diff --git a/build.gradle b/build.gradle index 2a163c13..5b561572 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'mysql:mysql-connector-java' diff --git a/src/main/java/com/alzzaipo/common/Uid.java b/src/main/java/com/alzzaipo/common/Uid.java index 3dd6e693..5626c4b7 100644 --- a/src/main/java/com/alzzaipo/common/Uid.java +++ b/src/main/java/com/alzzaipo/common/Uid.java @@ -17,6 +17,11 @@ public Uid(Long uid) { selfValidate(); } + public Uid(String uid) { + this.uid = Long.parseLong(uid); + selfValidate(); + } + private void selfValidate() { if (uid == null) { throw new CustomException(HttpStatus.BAD_REQUEST, "UID 오류 : null"); diff --git a/src/main/java/com/alzzaipo/common/jwt/JwtFilter.java b/src/main/java/com/alzzaipo/common/config/JwtFilter.java similarity index 81% rename from src/main/java/com/alzzaipo/common/jwt/JwtFilter.java rename to src/main/java/com/alzzaipo/common/config/JwtFilter.java index 7c0aa10a..98cf7590 100644 --- a/src/main/java/com/alzzaipo/common/jwt/JwtFilter.java +++ b/src/main/java/com/alzzaipo/common/config/JwtFilter.java @@ -1,12 +1,11 @@ -package com.alzzaipo.common.jwt; +package com.alzzaipo.common.config; import com.alzzaipo.common.LoginType; import com.alzzaipo.common.MemberPrincipal; import com.alzzaipo.common.Uid; +import com.alzzaipo.common.jwt.JwtUtil; import com.alzzaipo.member.adapter.out.persistence.member.MemberJpaEntity; import com.alzzaipo.member.adapter.out.persistence.member.MemberRepository; -import io.jsonwebtoken.ExpiredJwtException; -import io.jsonwebtoken.security.SignatureException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -43,7 +42,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String token = resolveTokenFromAuthorizationHeader(request); if (token == null) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write("Token Not Found"); + response.getWriter().write("Missing Token"); filterChain.doFilter(request, response); return; } @@ -57,19 +56,10 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse SecurityContextHolder.getContext().setAuthentication(authentication); filterChain.doFilter(request, response); - } catch (ExpiredJwtException e) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write("Token has been expired"); - } catch (SignatureException | BadCredentialsException e) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write("Invalid Token"); - } catch (UsernameNotFoundException e) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); - response.getWriter().write("User Not Found"); } catch (Exception e) { logger.error(e.getMessage()); - response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); - response.getWriter().write("Authentication Error"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Invalid Token"); } } @@ -97,17 +87,15 @@ private MemberPrincipal createPrincipal(String token) { @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - List excludePath = Arrays.asList( + List whitelist = Arrays.asList( "/member/verify-account-id", "/member/verify-email", "/member/register", - "/member/login", "/ipo", "/email", - "/oauth/kakao/login"); + "/login"); String path = request.getRequestURI(); - - return excludePath.stream().anyMatch(path::startsWith); + return whitelist.stream().anyMatch(path::startsWith); } } diff --git a/src/main/java/com/alzzaipo/common/config/RedisConfig.java b/src/main/java/com/alzzaipo/common/config/RedisConfig.java new file mode 100644 index 00000000..b8eb0b9b --- /dev/null +++ b/src/main/java/com/alzzaipo/common/config/RedisConfig.java @@ -0,0 +1,35 @@ +package com.alzzaipo.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + return redisTemplate; + } +} diff --git a/src/main/java/com/alzzaipo/common/config/SecurityConfig.java b/src/main/java/com/alzzaipo/common/config/SecurityConfig.java index 2ec2ade0..766bc139 100644 --- a/src/main/java/com/alzzaipo/common/config/SecurityConfig.java +++ b/src/main/java/com/alzzaipo/common/config/SecurityConfig.java @@ -1,6 +1,5 @@ package com.alzzaipo.common.config; -import com.alzzaipo.common.jwt.JwtFilter; import com.alzzaipo.member.domain.member.Role; import com.alzzaipo.member.adapter.out.persistence.member.MemberRepository; import java.util.List; @@ -42,7 +41,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/member/login", "/ipo/**", "/email/**", - "/oauth/kakao/login").permitAll() + "/login/**").permitAll() .requestMatchers("/scraper").hasRole(Role.ADMIN.name()) .anyRequest().authenticated()); diff --git a/src/main/java/com/alzzaipo/common/jwt/JwtProperties.java b/src/main/java/com/alzzaipo/common/jwt/JwtProperties.java index 6478d721..5a5ed6e5 100644 --- a/src/main/java/com/alzzaipo/common/jwt/JwtProperties.java +++ b/src/main/java/com/alzzaipo/common/jwt/JwtProperties.java @@ -12,5 +12,6 @@ public class JwtProperties { private String secretKey; - private Long expirationTimeMillis; + private Long accessTokenExpirationTimeMillis; + private Long refreshTokenExpirationTimeMillis; } \ No newline at end of file diff --git a/src/main/java/com/alzzaipo/common/jwt/JwtUtil.java b/src/main/java/com/alzzaipo/common/jwt/JwtUtil.java index f54e6261..a42fca44 100644 --- a/src/main/java/com/alzzaipo/common/jwt/JwtUtil.java +++ b/src/main/java/com/alzzaipo/common/jwt/JwtUtil.java @@ -22,17 +22,10 @@ private JwtUtil(JwtProperties jwtProperties) { JwtUtil.jwtProperties = jwtProperties; } - public static String createToken(Uid memberUID, LoginType loginType) { - Claims claims = Jwts.claims(); - claims.setSubject(memberUID.toString()); - claims.put("loginType", loginType.name()); - - return Jwts.builder() - .setClaims(claims) - .setIssuedAt(new Date()) - .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getExpirationTimeMillis())) - .signWith(getSecretKey(), SignatureAlgorithm.HS256) - .compact(); + public static TokenInfo createToken(Uid memberId, LoginType loginType) { + String accessToken = generateToken(memberId, loginType, jwtProperties.getAccessTokenExpirationTimeMillis()); + String refreshToken = generateToken(null, loginType, jwtProperties.getRefreshTokenExpirationTimeMillis()); + return new TokenInfo(accessToken, refreshToken); } public static Uid getMemberUID(String token) { @@ -44,6 +37,30 @@ public static LoginType getLoginType(String token) { return LoginType.valueOf((getClaims(token).get("loginType", String.class))); } + public static boolean validate(String token) { + try { + Jwts.parserBuilder().setSigningKey(getSecretKey()).build().parseClaimsJws(token); + } catch(Exception e) { + return false; + } + return true; + } + + private static String generateToken(Uid memberId, LoginType loginType, long expirationTimeMillis) { + Claims claims = Jwts.claims(); + claims.put("loginType", loginType.name()); + if(memberId != null) { + claims.setSubject(memberId.toString()); + } + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + expirationTimeMillis)) + .signWith(getSecretKey(), SignatureAlgorithm.HS256) + .compact(); + } + private static Claims getClaims(String token) { return Jwts.parserBuilder() .setSigningKey(getSecretKey()) diff --git a/src/main/java/com/alzzaipo/common/jwt/TokenInfo.java b/src/main/java/com/alzzaipo/common/jwt/TokenInfo.java new file mode 100644 index 00000000..440ab526 --- /dev/null +++ b/src/main/java/com/alzzaipo/common/jwt/TokenInfo.java @@ -0,0 +1,15 @@ +package com.alzzaipo.common.jwt; + +import lombok.Getter; + +@Getter +public class TokenInfo { + + private final String accessToken; + private final String RefreshToken; + + public TokenInfo(String accessToken, String refreshToken) { + this.accessToken = accessToken; + RefreshToken = refreshToken; + } +} diff --git a/src/main/java/com/alzzaipo/member/adapter/in/web/LoginController.java b/src/main/java/com/alzzaipo/member/adapter/in/web/LoginController.java new file mode 100644 index 00000000..d989698a --- /dev/null +++ b/src/main/java/com/alzzaipo/member/adapter/in/web/LoginController.java @@ -0,0 +1,67 @@ +package com.alzzaipo.member.adapter.in.web; + +import com.alzzaipo.common.jwt.TokenInfo; +import com.alzzaipo.member.adapter.in.web.dto.LocalLoginWebRequest; +import com.alzzaipo.member.adapter.in.web.dto.RefreshTokenDto; +import com.alzzaipo.member.adapter.in.web.dto.TokenResponse; +import com.alzzaipo.member.application.port.in.RefreshTokenUseCase; +import com.alzzaipo.member.application.port.in.account.local.LocalLoginUseCase; +import com.alzzaipo.member.application.port.in.dto.AuthorizationCode; +import com.alzzaipo.member.application.port.in.dto.LocalLoginCommand; +import com.alzzaipo.member.application.port.in.dto.LoginResult; +import com.alzzaipo.member.application.port.in.oauth.KakaoLoginUseCase; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/login") +@RequiredArgsConstructor +public class LoginController { + + private final LocalLoginUseCase localLoginUseCase; + private final KakaoLoginUseCase kakaoLoginUseCase; + private final RefreshTokenUseCase refreshTokenUseCase; + + @PostMapping("/local") + public ResponseEntity login(@Valid @RequestBody LocalLoginWebRequest dto) { + LocalLoginCommand localLoginCommand = new LocalLoginCommand( + dto.getAccountId(), + dto.getAccountPassword()); + + LoginResult loginResult = localLoginUseCase.handleLocalLogin(localLoginCommand); + + if (loginResult.isSuccess()) { + TokenResponse tokenResponse = TokenResponse.build(loginResult.getTokenInfo()); + return ResponseEntity.ok(tokenResponse); + } + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + @GetMapping("/kakao") + public ResponseEntity kakaoLogin(@RequestParam("code") String authCode) { + AuthorizationCode authorizationCode = new AuthorizationCode(authCode); + + LoginResult loginResult = kakaoLoginUseCase.handleLogin(authorizationCode); + + if (loginResult.isSuccess()) { + TokenResponse tokenResponse = TokenResponse.build(loginResult.getTokenInfo()); + return ResponseEntity.ok(tokenResponse); + } + return ResponseEntity.badRequest().build(); + } + + @PostMapping("/refresh-token") + public ResponseEntity refreshToken(@Valid @RequestBody RefreshTokenDto dto) { + TokenInfo tokenInfo = refreshTokenUseCase.refresh(dto.getRefreshToken()); + TokenResponse tokenResponse = TokenResponse.build(tokenInfo); + return ResponseEntity.ok(tokenResponse); + } +} diff --git a/src/main/java/com/alzzaipo/member/adapter/in/web/MemberController.java b/src/main/java/com/alzzaipo/member/adapter/in/web/MemberController.java index 2f7eb94e..d6bcff9d 100644 --- a/src/main/java/com/alzzaipo/member/adapter/in/web/MemberController.java +++ b/src/main/java/com/alzzaipo/member/adapter/in/web/MemberController.java @@ -5,18 +5,14 @@ import com.alzzaipo.common.Uid; import com.alzzaipo.member.adapter.in.web.dto.ChangeLocalAccountPasswordWebRequest; import com.alzzaipo.member.adapter.in.web.dto.LocalAccountPasswordDto; -import com.alzzaipo.member.adapter.in.web.dto.LocalLoginWebRequest; import com.alzzaipo.member.adapter.in.web.dto.RegisterLocalAccountWebRequest; import com.alzzaipo.member.adapter.in.web.dto.UpdateMemberProfileWebRequest; import com.alzzaipo.member.application.port.in.account.local.ChangeLocalAccountPasswordUseCase; import com.alzzaipo.member.application.port.in.account.local.CheckLocalAccountEmailAvailabilityQuery; import com.alzzaipo.member.application.port.in.account.local.CheckLocalAccountIdAvailabilityQuery; import com.alzzaipo.member.application.port.in.account.local.CheckLocalAccountPasswordQuery; -import com.alzzaipo.member.application.port.in.account.local.LocalLoginUseCase; import com.alzzaipo.member.application.port.in.account.local.RegisterLocalAccountUseCase; import com.alzzaipo.member.application.port.in.dto.ChangeLocalAccountPasswordCommand; -import com.alzzaipo.member.application.port.in.dto.LocalLoginCommand; -import com.alzzaipo.member.application.port.in.dto.LoginResult; import com.alzzaipo.member.application.port.in.dto.MemberProfile; import com.alzzaipo.member.application.port.in.dto.RegisterLocalAccountCommand; import com.alzzaipo.member.application.port.in.dto.UpdateMemberProfileCommand; @@ -48,7 +44,6 @@ public class MemberController { private final RegisterLocalAccountUseCase registerLocalAccountUseCase; private final CheckLocalAccountIdAvailabilityQuery checkLocalAccountIdAvailabilityQuery; private final CheckLocalAccountEmailAvailabilityQuery checkLocalAccountEmailAvailabilityQuery; - private final LocalLoginUseCase localLoginUseCase; private final CheckLocalAccountPasswordQuery checkLocalAccountPasswordQuery; private final ChangeLocalAccountPasswordUseCase changeLocalAccountPasswordUseCase; private final FindMemberNicknameQuery findMemberNicknameQuery; @@ -57,9 +52,7 @@ public class MemberController { private final WithdrawMemberUseCase withdrawMemberUseCase; @GetMapping("/verify-account-id") - public ResponseEntity checkLocalAccountIdAvailability( - @RequestParam("accountId") String accountId) { - + public ResponseEntity checkLocalAccountIdAvailability(@RequestParam("accountId") String accountId) { LocalAccountId localAccountId = new LocalAccountId(accountId); if (!checkLocalAccountIdAvailabilityQuery.checkLocalAccountIdAvailability(localAccountId)) { @@ -69,22 +62,17 @@ public ResponseEntity checkLocalAccountIdAvailability( } @GetMapping("/verify-email") - public ResponseEntity checkLocalAccountEmailAvailability( - @RequestParam("email") String email) { - + public ResponseEntity checkLocalAccountEmailAvailability(@RequestParam("email") String email) { Email localAccountEmail = new Email(email); - if (!checkLocalAccountEmailAvailabilityQuery.checkLocalAccountEmailAvailability( - localAccountEmail)) { + if (!checkLocalAccountEmailAvailabilityQuery.checkLocalAccountEmailAvailability(localAccountEmail)) { return ResponseEntity.status(HttpStatus.CONFLICT).body("이미 등록된 이메일 입니다."); } return ResponseEntity.ok().body("사용 가능한 이메일 입니다."); } @PostMapping("/register") - public ResponseEntity register( - @Valid @RequestBody RegisterLocalAccountWebRequest dto) { - + public ResponseEntity register(@Valid @RequestBody RegisterLocalAccountWebRequest dto) { RegisterLocalAccountCommand command = new RegisterLocalAccountCommand( dto.getAccountId(), dto.getAccountPassword(), @@ -96,34 +84,14 @@ public ResponseEntity register( return ResponseEntity.ok().body("가입 완료"); } - @PostMapping("/login") - public ResponseEntity login(@Valid @RequestBody LocalLoginWebRequest dto) { - - LocalLoginCommand localLoginCommand = new LocalLoginCommand( - dto.getAccountId(), - dto.getAccountPassword()); - - LoginResult loginResult = localLoginUseCase.handleLocalLogin(localLoginCommand); - - if (loginResult.isSuccess()) { - return ResponseEntity.ok() - .header("Authorization", "Bearer " + loginResult.getToken()) - .body("로그인 성공"); - } - - return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("로그인 실패"); - } - @PostMapping("/verify-password") public ResponseEntity verifyPassword(@AuthenticationPrincipal MemberPrincipal principal, @Valid @RequestBody LocalAccountPasswordDto dto) { Uid memberUID = principal.getMemberUID(); - LocalAccountPassword localAccountPassword = new LocalAccountPassword( - dto.getAccountPassword()); + LocalAccountPassword localAccountPassword = new LocalAccountPassword(dto.getAccountPassword()); - if (checkLocalAccountPasswordQuery.checkLocalAccountPassword(memberUID, - localAccountPassword)) { + if (checkLocalAccountPasswordQuery.checkLocalAccountPassword(memberUID, localAccountPassword)) { return ResponseEntity.ok().body("비밀번호 검증 성공"); } return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("비밀번호 검증 실패"); @@ -134,18 +102,13 @@ public ResponseEntity changePassword(@AuthenticationPrincipal MemberPrin @Valid @RequestBody ChangeLocalAccountPasswordWebRequest dto) { Uid memberUID = principal.getMemberUID(); - - LocalAccountPassword currentAccountPassword = new LocalAccountPassword( - dto.getCurrentAccountPassword()); - - LocalAccountPassword newAccountPassword = new LocalAccountPassword( - dto.getNewAccountPassword()); + LocalAccountPassword currentAccountPassword = new LocalAccountPassword(dto.getCurrentAccountPassword()); + LocalAccountPassword newAccountPassword = new LocalAccountPassword(dto.getNewAccountPassword()); ChangeLocalAccountPasswordCommand command = new ChangeLocalAccountPasswordCommand(memberUID, newAccountPassword); - if (!checkLocalAccountPasswordQuery.checkLocalAccountPassword(memberUID, - currentAccountPassword)) { + if (!checkLocalAccountPasswordQuery.checkLocalAccountPassword(memberUID, currentAccountPassword)) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("비밀번호 검증 실패"); } @@ -156,27 +119,21 @@ public ResponseEntity changePassword(@AuthenticationPrincipal MemberPrin } @GetMapping("/nickname") - public ResponseEntity findMemberNickname( - @AuthenticationPrincipal MemberPrincipal principal) { - + public ResponseEntity findMemberNickname(@AuthenticationPrincipal MemberPrincipal principal) { String nickname = findMemberNicknameQuery.findMemberNickname(principal.getMemberUID()); return ResponseEntity.ok().body(nickname); } @GetMapping("/profile") - public ResponseEntity findMemberProfile( - @AuthenticationPrincipal MemberPrincipal principal) { - - MemberProfile memberProfile = findMemberProfileQuery.findMemberProfile( - principal.getMemberUID(), + public ResponseEntity findMemberProfile(@AuthenticationPrincipal MemberPrincipal principal) { + MemberProfile memberProfile = findMemberProfileQuery.findMemberProfile(principal.getMemberUID(), principal.getCurrentLoginType()); return ResponseEntity.ok().body(memberProfile); } @PutMapping("/profile/update") - public ResponseEntity updateMemberProfile( - @AuthenticationPrincipal MemberPrincipal principal, + public ResponseEntity updateMemberProfile(@AuthenticationPrincipal MemberPrincipal principal, @Valid @RequestBody UpdateMemberProfileWebRequest dto) { UpdateMemberProfileCommand command = new UpdateMemberProfileCommand( @@ -190,9 +147,7 @@ public ResponseEntity updateMemberProfile( } @DeleteMapping("/withdraw") - public ResponseEntity withdrawMember( - @AuthenticationPrincipal MemberPrincipal principal) { - + public ResponseEntity withdrawMember(@AuthenticationPrincipal MemberPrincipal principal) { withdrawMemberUseCase.withdrawMember(principal.getMemberUID()); return ResponseEntity.ok().body("회원 탈퇴 완료"); } diff --git a/src/main/java/com/alzzaipo/member/adapter/in/web/OAuthController.java b/src/main/java/com/alzzaipo/member/adapter/in/web/OAuthController.java index 2d004672..a111e551 100644 --- a/src/main/java/com/alzzaipo/member/adapter/in/web/OAuthController.java +++ b/src/main/java/com/alzzaipo/member/adapter/in/web/OAuthController.java @@ -4,58 +4,36 @@ import com.alzzaipo.common.MemberPrincipal; import com.alzzaipo.member.application.port.in.account.social.UnlinkSocialAccountUseCase; import com.alzzaipo.member.application.port.in.dto.AuthorizationCode; -import com.alzzaipo.member.application.port.in.dto.LoginResult; import com.alzzaipo.member.application.port.in.dto.UnlinkSocialAccountCommand; -import com.alzzaipo.member.application.port.in.oauth.KakaoLoginUseCase; import com.alzzaipo.member.application.port.in.oauth.LinkKakaoAccountUseCase; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/oauth") @RequiredArgsConstructor public class OAuthController { - private final KakaoLoginUseCase kakaoLoginUseCase; - private final LinkKakaoAccountUseCase linkKakaoAccountUseCase; - private final UnlinkSocialAccountUseCase unlinkSocialAccountUseCase; - - @GetMapping("/kakao/login") - public ResponseEntity kakaoLogin(@RequestParam("code") String authCode) { - AuthorizationCode authorizationCode = new AuthorizationCode(authCode); - - LoginResult loginResult = kakaoLoginUseCase.handleLogin(authorizationCode); - - if (loginResult.isSuccess()) { - String token = loginResult.getToken(); - - return ResponseEntity.ok() - .header("Authorization", token) - .body("로그인 성공"); - } - return ResponseEntity.badRequest().body("로그인 실패"); - } - - @GetMapping("/kakao/link") - public ResponseEntity kakaoLink(@AuthenticationPrincipal MemberPrincipal principal, - @RequestParam("code") String authCode) { - linkKakaoAccountUseCase.linkKakaoAccount( - principal.getMemberUID(), - new AuthorizationCode(authCode)); - - return ResponseEntity.ok().body("연동 완료"); - } - - @DeleteMapping("/kakao/unlink") - public ResponseEntity kakaoUnlink(@AuthenticationPrincipal MemberPrincipal principal) { - UnlinkSocialAccountCommand command = new UnlinkSocialAccountCommand( - principal.getMemberUID(), - LoginType.KAKAO); - - unlinkSocialAccountUseCase.unlinkSocialAccountUseCase(command); - - return ResponseEntity.ok().body("해지 완료"); - } + private final LinkKakaoAccountUseCase linkKakaoAccountUseCase; + private final UnlinkSocialAccountUseCase unlinkSocialAccountUseCase; + + @GetMapping("/kakao/link") + public ResponseEntity kakaoLink(@AuthenticationPrincipal MemberPrincipal principal, + @RequestParam("code") String authCode) { + linkKakaoAccountUseCase.linkKakaoAccount(principal.getMemberUID(), new AuthorizationCode(authCode)); + return ResponseEntity.ok().body("연동 완료"); + } + + @DeleteMapping("/kakao/unlink") + public ResponseEntity kakaoUnlink(@AuthenticationPrincipal MemberPrincipal principal) { + UnlinkSocialAccountCommand command = new UnlinkSocialAccountCommand(principal.getMemberUID(), LoginType.KAKAO); + unlinkSocialAccountUseCase.unlinkSocialAccountUseCase(command); + return ResponseEntity.ok().body("해지 완료"); + } } diff --git a/src/main/java/com/alzzaipo/member/adapter/in/web/dto/RefreshTokenDto.java b/src/main/java/com/alzzaipo/member/adapter/in/web/dto/RefreshTokenDto.java new file mode 100644 index 00000000..cc9e8d1a --- /dev/null +++ b/src/main/java/com/alzzaipo/member/adapter/in/web/dto/RefreshTokenDto.java @@ -0,0 +1,15 @@ +package com.alzzaipo.member.adapter.in.web.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class RefreshTokenDto { + + @NotBlank + private String refreshToken; +} diff --git a/src/main/java/com/alzzaipo/member/adapter/in/web/dto/TokenResponse.java b/src/main/java/com/alzzaipo/member/adapter/in/web/dto/TokenResponse.java new file mode 100644 index 00000000..061206d2 --- /dev/null +++ b/src/main/java/com/alzzaipo/member/adapter/in/web/dto/TokenResponse.java @@ -0,0 +1,20 @@ +package com.alzzaipo.member.adapter.in.web.dto; + +import com.alzzaipo.common.jwt.TokenInfo; +import lombok.Getter; + +@Getter +public class TokenResponse { + + private final String accessToken; + private final String refreshToken; + + public TokenResponse(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public static TokenResponse build(TokenInfo tokenInfo) { + return new TokenResponse(tokenInfo.getAccessToken(), tokenInfo.getRefreshToken()); + } +} diff --git a/src/main/java/com/alzzaipo/member/adapter/out/token/RefreshTokenPersistenceAdapter.java b/src/main/java/com/alzzaipo/member/adapter/out/token/RefreshTokenPersistenceAdapter.java new file mode 100644 index 00000000..f9c1fe51 --- /dev/null +++ b/src/main/java/com/alzzaipo/member/adapter/out/token/RefreshTokenPersistenceAdapter.java @@ -0,0 +1,49 @@ +package com.alzzaipo.member.adapter.out.token; + +import com.alzzaipo.common.Uid; +import com.alzzaipo.common.exception.CustomException; +import com.alzzaipo.common.jwt.JwtProperties; +import com.alzzaipo.member.application.port.out.FindRefreshTokenPort; +import com.alzzaipo.member.application.port.out.RenewRefreshTokenPort; +import com.alzzaipo.member.application.port.out.SaveRefreshTokenPort; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RefreshTokenPersistenceAdapter implements SaveRefreshTokenPort, + FindRefreshTokenPort, + RenewRefreshTokenPort { + + private final RedisTemplate redisTemplate; + private final JwtProperties jwtProperties; + + @Override + public void save(String token, Uid memberId) { + Long expirationTimeMillis = jwtProperties.getRefreshTokenExpirationTimeMillis(); + redisTemplate.opsForValue().set(token, memberId.toString(), expirationTimeMillis, TimeUnit.MILLISECONDS); + } + + @Override + public Optional find(String refreshToken) { + String memberId = redisTemplate.opsForValue().get(refreshToken); + if (memberId != null) { + return Optional.of(new Uid(memberId)); + } + return Optional.empty(); + } + + @Override + public void renew(String oldRefreshToken, String newRefreshToken) { + String memberId = redisTemplate.opsForValue().get(oldRefreshToken); + if (memberId == null) { + throw new CustomException(HttpStatus.UNAUTHORIZED, "Invalid Token"); + } + redisTemplate.delete(oldRefreshToken); + redisTemplate.opsForValue().set(newRefreshToken, memberId); + } +} diff --git a/src/main/java/com/alzzaipo/member/application/port/in/RefreshTokenUseCase.java b/src/main/java/com/alzzaipo/member/application/port/in/RefreshTokenUseCase.java new file mode 100644 index 00000000..a4ef058b --- /dev/null +++ b/src/main/java/com/alzzaipo/member/application/port/in/RefreshTokenUseCase.java @@ -0,0 +1,8 @@ +package com.alzzaipo.member.application.port.in; + +import com.alzzaipo.common.jwt.TokenInfo; + +public interface RefreshTokenUseCase { + + TokenInfo refresh(String refreshToken); +} diff --git a/src/main/java/com/alzzaipo/member/application/port/in/dto/LoginResult.java b/src/main/java/com/alzzaipo/member/application/port/in/dto/LoginResult.java index 129d0e67..6d9b755c 100644 --- a/src/main/java/com/alzzaipo/member/application/port/in/dto/LoginResult.java +++ b/src/main/java/com/alzzaipo/member/application/port/in/dto/LoginResult.java @@ -1,19 +1,20 @@ package com.alzzaipo.member.application.port.in.dto; +import com.alzzaipo.common.jwt.TokenInfo; import lombok.Getter; @Getter public class LoginResult { - private final boolean success; - private final String token; + private final boolean success; + private final TokenInfo tokenInfo; - public LoginResult(boolean success, String token) { - this.success = success; - this.token = token; - } + public LoginResult(boolean success, TokenInfo tokenInfo) { + this.success = success; + this.tokenInfo = tokenInfo; + } - public static LoginResult getFailedResult() { - return new LoginResult(false, ""); - } + public static LoginResult getFailedResult() { + return new LoginResult(false, null); + } } diff --git a/src/main/java/com/alzzaipo/member/application/port/out/FindRefreshTokenPort.java b/src/main/java/com/alzzaipo/member/application/port/out/FindRefreshTokenPort.java new file mode 100644 index 00000000..31b6790b --- /dev/null +++ b/src/main/java/com/alzzaipo/member/application/port/out/FindRefreshTokenPort.java @@ -0,0 +1,9 @@ +package com.alzzaipo.member.application.port.out; + +import com.alzzaipo.common.Uid; +import java.util.Optional; + +public interface FindRefreshTokenPort { + + Optional find(String refreshToken); +} diff --git a/src/main/java/com/alzzaipo/member/application/port/out/RenewRefreshTokenPort.java b/src/main/java/com/alzzaipo/member/application/port/out/RenewRefreshTokenPort.java new file mode 100644 index 00000000..02eb93ba --- /dev/null +++ b/src/main/java/com/alzzaipo/member/application/port/out/RenewRefreshTokenPort.java @@ -0,0 +1,6 @@ +package com.alzzaipo.member.application.port.out; + +public interface RenewRefreshTokenPort { + + void renew(String oldRefreshToken, String newRefreshToken); +} diff --git a/src/main/java/com/alzzaipo/member/application/port/out/SaveRefreshTokenPort.java b/src/main/java/com/alzzaipo/member/application/port/out/SaveRefreshTokenPort.java new file mode 100644 index 00000000..da064a45 --- /dev/null +++ b/src/main/java/com/alzzaipo/member/application/port/out/SaveRefreshTokenPort.java @@ -0,0 +1,8 @@ +package com.alzzaipo.member.application.port.out; + +import com.alzzaipo.common.Uid; + +public interface SaveRefreshTokenPort { + + void save(String token, Uid memberId); +} diff --git a/src/main/java/com/alzzaipo/member/application/service/account/local/LocalLoginService.java b/src/main/java/com/alzzaipo/member/application/service/account/local/LocalLoginService.java deleted file mode 100644 index 670e90bd..00000000 --- a/src/main/java/com/alzzaipo/member/application/service/account/local/LocalLoginService.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.alzzaipo.member.application.service.account.local; - -import com.alzzaipo.common.LoginType; -import com.alzzaipo.common.jwt.JwtUtil; -import com.alzzaipo.member.application.port.in.dto.LocalLoginCommand; -import com.alzzaipo.member.application.port.in.dto.LoginResult; -import com.alzzaipo.member.application.port.in.account.local.LocalLoginUseCase; -import com.alzzaipo.member.application.port.out.account.local.FindLocalAccountByAccountIdPort; -import com.alzzaipo.member.application.port.out.dto.SecureLocalAccount; -import lombok.RequiredArgsConstructor; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@Component -@RequiredArgsConstructor -public class LocalLoginService implements LocalLoginUseCase { - - private final PasswordEncoder passwordEncoder; - private final FindLocalAccountByAccountIdPort findLocalAccountByAccountIdPort; - - @Override - public LoginResult handleLocalLogin(LocalLoginCommand command) { - Optional optionalSecureLocalAccount - = findLocalAccountByAccountIdPort.findLocalAccountByAccountId(command.getLocalAccountId()); - - if (optionalSecureLocalAccount.isPresent() && isPasswordValid(command, optionalSecureLocalAccount.get())) { - String token = createJwtToken(optionalSecureLocalAccount.get()); - return new LoginResult(true, token); - } - - return LoginResult.getFailedResult(); - } - - private boolean isPasswordValid(LocalLoginCommand command, SecureLocalAccount secureLocalAccount) { - String userProvidedPassword = command.getLocalAccountPassword().get(); - String validEncryptedPassword = secureLocalAccount.getEncryptedAccountPassword(); - return passwordEncoder.matches(userProvidedPassword, validEncryptedPassword); - } - - private String createJwtToken(SecureLocalAccount secureLocalAccount) { - return JwtUtil.createToken(secureLocalAccount.getMemberUID(), LoginType.LOCAL); - } -} diff --git a/src/main/java/com/alzzaipo/member/application/service/account/social/KakaoLoginService.java b/src/main/java/com/alzzaipo/member/application/service/login/KakaoLoginService.java similarity index 70% rename from src/main/java/com/alzzaipo/member/application/service/account/social/KakaoLoginService.java rename to src/main/java/com/alzzaipo/member/application/service/login/KakaoLoginService.java index a0bd1e00..84a24715 100644 --- a/src/main/java/com/alzzaipo/member/application/service/account/social/KakaoLoginService.java +++ b/src/main/java/com/alzzaipo/member/application/service/login/KakaoLoginService.java @@ -1,10 +1,12 @@ -package com.alzzaipo.member.application.service.account.social; +package com.alzzaipo.member.application.service.login; import com.alzzaipo.common.LoginType; import com.alzzaipo.common.jwt.JwtUtil; +import com.alzzaipo.common.jwt.TokenInfo; import com.alzzaipo.member.application.port.in.dto.AuthorizationCode; import com.alzzaipo.member.application.port.in.dto.LoginResult; import com.alzzaipo.member.application.port.in.oauth.KakaoLoginUseCase; +import com.alzzaipo.member.application.port.out.SaveRefreshTokenPort; import com.alzzaipo.member.application.port.out.account.social.FindSocialAccountPort; import com.alzzaipo.member.application.port.out.account.social.RegisterSocialAccountPort; import com.alzzaipo.member.application.port.out.dto.AccessToken; @@ -22,50 +24,42 @@ @RequiredArgsConstructor public class KakaoLoginService implements KakaoLoginUseCase { - private final static LoginType LOGIN_TYPE = LoginType.KAKAO; + private final static LoginType KAKAO_LOGIN_TYPE = LoginType.KAKAO; private final ExchangeKakaoAccessTokenPort exchangeKakaoAccessTokenPort; private final FetchKakaoUserProfilePort fetchKakaoUserProfilePort; private final FindSocialAccountPort findSocialAccountPort; private final RegisterMemberPort registerMemberPort; private final RegisterSocialAccountPort registerSocialAccountPort; + private final SaveRefreshTokenPort saveRefreshTokenPort; @Override public LoginResult handleLogin(AuthorizationCode authorizationCode) { - LoginResult loginResult; - try { - AccessToken accessToken + AccessToken kakaoAccessToken = exchangeKakaoAccessTokenPort.exchangeKakaoAccessToken(authorizationCode); - UserProfile userProfile - = fetchKakaoUserProfilePort.fetchKakaoUserProfile(accessToken); - - FindSocialAccountCommand command = new FindSocialAccountCommand(LOGIN_TYPE, - userProfile.getEmail()); + UserProfile kakaoUserProfile + = fetchKakaoUserProfilePort.fetchKakaoUserProfile(kakaoAccessToken); + FindSocialAccountCommand command = new FindSocialAccountCommand(KAKAO_LOGIN_TYPE, kakaoUserProfile.getEmail()); SocialAccount socialAccount = findSocialAccountPort.findSocialAccount(command) - .orElseGet(() -> registerSocialAccount(userProfile)); + .orElseGet(() -> registerSocialAccount(kakaoUserProfile)); + + TokenInfo tokenInfo = JwtUtil.createToken(socialAccount.getMemberUID(), KAKAO_LOGIN_TYPE); + saveRefreshTokenPort.save(tokenInfo.getRefreshToken(), socialAccount.getMemberUID()); - String token = JwtUtil.createToken(socialAccount.getMemberUID(), LOGIN_TYPE); - loginResult = new LoginResult(true, token); + return new LoginResult(true, tokenInfo); } catch (Exception e) { - loginResult = LoginResult.getFailedResult(); + return LoginResult.getFailedResult(); } - - return loginResult; } private SocialAccount registerSocialAccount(UserProfile userProfile) { Member member = Member.create(userProfile.getNickname()); - - SocialAccount socialAccount = new SocialAccount( - member.getUid(), - userProfile.getEmail(), - LOGIN_TYPE); - registerMemberPort.registerMember(member); + SocialAccount socialAccount = new SocialAccount(member.getUid(), userProfile.getEmail(), KAKAO_LOGIN_TYPE); registerSocialAccountPort.registerSocialAccount(socialAccount); return socialAccount; diff --git a/src/main/java/com/alzzaipo/member/application/service/login/LocalLoginService.java b/src/main/java/com/alzzaipo/member/application/service/login/LocalLoginService.java new file mode 100644 index 00000000..1fcb05a7 --- /dev/null +++ b/src/main/java/com/alzzaipo/member/application/service/login/LocalLoginService.java @@ -0,0 +1,45 @@ +package com.alzzaipo.member.application.service.login; + +import com.alzzaipo.common.LoginType; +import com.alzzaipo.common.Uid; +import com.alzzaipo.common.jwt.JwtUtil; +import com.alzzaipo.common.jwt.TokenInfo; +import com.alzzaipo.member.application.port.in.account.local.LocalLoginUseCase; +import com.alzzaipo.member.application.port.in.dto.LocalLoginCommand; +import com.alzzaipo.member.application.port.in.dto.LoginResult; +import com.alzzaipo.member.application.port.out.SaveRefreshTokenPort; +import com.alzzaipo.member.application.port.out.account.local.FindLocalAccountByAccountIdPort; +import com.alzzaipo.member.application.port.out.dto.SecureLocalAccount; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class LocalLoginService implements LocalLoginUseCase { + + private final PasswordEncoder passwordEncoder; + private final SaveRefreshTokenPort saveRefreshTokenPort; + private final FindLocalAccountByAccountIdPort findLocalAccountByAccountIdPort; + + @Override + public LoginResult handleLocalLogin(LocalLoginCommand command) { + Optional optionalLocalAccount + = findLocalAccountByAccountIdPort.findLocalAccountByAccountId(command.getLocalAccountId()); + + if (optionalLocalAccount.isPresent() && isPasswordValid(command, optionalLocalAccount.get())) { + Uid memberId = optionalLocalAccount.get().getMemberUID(); + TokenInfo tokenInfo = JwtUtil.createToken(memberId, LoginType.LOCAL); + saveRefreshTokenPort.save(tokenInfo.getRefreshToken(), memberId); + return new LoginResult(true, tokenInfo); + } + return LoginResult.getFailedResult(); + } + + private boolean isPasswordValid(LocalLoginCommand command, SecureLocalAccount secureLocalAccount) { + String userProvidedPassword = command.getLocalAccountPassword().get(); + String validEncryptedPassword = secureLocalAccount.getEncryptedAccountPassword(); + return passwordEncoder.matches(userProvidedPassword, validEncryptedPassword); + } +} diff --git a/src/main/java/com/alzzaipo/member/application/service/login/RefreshTokenService.java b/src/main/java/com/alzzaipo/member/application/service/login/RefreshTokenService.java new file mode 100644 index 00000000..b579ca16 --- /dev/null +++ b/src/main/java/com/alzzaipo/member/application/service/login/RefreshTokenService.java @@ -0,0 +1,49 @@ +package com.alzzaipo.member.application.service.login; + +import com.alzzaipo.common.Uid; +import com.alzzaipo.common.exception.CustomException; +import com.alzzaipo.common.jwt.JwtUtil; +import com.alzzaipo.common.jwt.TokenInfo; +import com.alzzaipo.member.application.port.in.RefreshTokenUseCase; +import com.alzzaipo.member.application.port.out.RenewRefreshTokenPort; +import com.alzzaipo.member.application.port.out.FindRefreshTokenPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefreshTokenService implements RefreshTokenUseCase { + + private final FindRefreshTokenPort findRefreshTokenPort; + private final RenewRefreshTokenPort renewRefreshTokenPort; + + @Override + public TokenInfo refresh(String refreshToken) { + validateToken(refreshToken); + + Uid memberId = findRefreshTokenPort.find(refreshToken) + .orElseThrow(() -> new CustomException(HttpStatus.UNAUTHORIZED, "Invalid Token")); + + checkOwnership(memberId, JwtUtil.getMemberUID(refreshToken)); + + TokenInfo tokenInfo = JwtUtil.createToken(memberId, JwtUtil.getLoginType(refreshToken)); + renewRefreshTokenPort.renew(refreshToken, tokenInfo.getRefreshToken()); + return tokenInfo; + } + + private void validateToken(String refreshToken) { + if (!JwtUtil.validate(refreshToken)) { + throw new CustomException(HttpStatus.UNAUTHORIZED, "Invalid Token"); + } + } + + private void checkOwnership(Uid storedMemberId, Uid tokenMemberId) { + if(!storedMemberId.equals(tokenMemberId)) { + log.warn("Suspicious Refreshing Token Attempted : original {} / attempted: {}", storedMemberId, tokenMemberId); + throw new CustomException(HttpStatus.UNAUTHORIZED, "Invalid Token"); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4e3a38e8..e5e62ed8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,6 +14,11 @@ spring: enable: true connectiontimeout: 5000 timeout: 30000 + data: + redis: + host: ${redis.host} + port: ${redis.port} + server: shutdown: graceful ---