EPguy
[Spring] Spring Security + JWT + 카카오 로그인 구현 본문
1. 확인 사항
📄 참고사항
해당 포스트는 서버에서 카카오 Access Token을 생성하는게 아닌,
클라이언트에서 카카오 Access Token 을 생성해서 서버에 전달하는 방식입니다.
그래서 이 포스트는 Oauth2 라이브러리를 사용하지 않습니다.
📄 로그인 흐름
1. 클라이언트에서 카카오로그인으로 생성된 AccessToken 을 서버로 전송.
2. 서버는 받은 AccessToken 을 가지고 카카오 API를 사용하여 유저정보를 가져옴.
3. 유저정보를 DB에 저장하고, AccessToken과 Refresh Token을 생성함.
4. Refresh Token 은 유저 테이블에 저장시켜준 다음, 클라이언트에서 사용할 수 있도록 Cookie 에도 저장시켜줌(Json으로 줘도 되지만 여기선 쿠키에 저장시켰습니다.).
5. 서버에서 생성한 AcessToken을 클라이언트에 Json으로 전달함으로써 로그인 프로세스 끝.
6. 클라이언트는 AccessToken이 만료될 때 마다 RefreshToken으로 AccessToken을 새로 갱신받음.
📄 사용 기술
- Spring Boot 3
- Spring Security
- MyBatis
- JWT
- 카카오로그인
📄 dependencies (build.gradle)
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.2'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter-test:3.0.2'
testImplementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
📄 application.properties
# 액세스토큰 만료시간(초)
# 리프레시토큰 만료시간(초)
# JWT 시크릿 키
jwt.access.token.expiration.seconds=3600
jwt.refresh.token.expiration.seconds=604800
jwt.token.secret-key=itsepguyitsepguyitsepgyitsitsits
# DB 및 Mapper 세팅
spring.datasource.url=jdbc:mysql://localhost:3306/duogo?useSSL=false&useUnicode=true&serverTimezone=Asia/Seoul
spring.datasource.username=root
spring.datasource.password=test
mybatis.mapper-locations=classpath:mapper/*.xml
📄 폴더 구조
├── auth
│ ├── config
│ ├── controller
│ ├── dto
│ ├── enums
│ ├── filter
│ ├── models
│ ├── service
│ └── utils
├── exception
├── user
│ ├── controller
│ ├── dto
│ ├── mapper
│ └── service
⭐ Github 소스
https://github.com/EPguy/Spring_Security_JWT_kakao_login
GitHub - EPguy/Spring_Security_JWT_kakao_login: Spring Security + JWT + kakao login 예제 프로젝트
Spring Security + JWT + kakao login 예제 프로젝트. Contribute to EPguy/Spring_Security_JWT_kakao_login development by creating an account on GitHub.
github.com
2. Security 및 JWT 관련 설정
🔒 OauthController
com.epguy.auth.controller
로그인 Controller
@RestController
@RequiredArgsConstructor
public class OauthController {
private final OauthService oauthService;
@PostMapping("/login/oauth/{provider}")
public OauthResponseDto login(@PathVariable String provider, @RequestBody OauthRequestDto oauthRequestDto,
HttpServletResponse response) {
OauthResponseDto oauthResponseDto = new OauthResponseDto();
switch (provider) {
case "kakao":
String accessToken = oauthService.loginWithKakao(oauthRequestDto.getAccessToken(), response);
oauthResponseDto.setAccessToken(accessToken);
}
return oauthResponseDto;
}
// 리프레시 토큰으로 액세스토큰 재발급 받는 로직
@PostMapping("/token/refresh")
public RefreshTokenResponseDto tokenRefresh(HttpServletRequest request) {
RefreshTokenResponseDto refreshTokenResponseDto = new RefreshTokenResponseDto();
Cookie[] list = request.getCookies();
if(list == null) {
throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
}
Cookie refreshTokenCookie = Arrays.stream(list).filter(cookie -> cookie.getName().equals("refresh_token")).collect(Collectors.toList()).get(0);
if(refreshTokenCookie == null) {
throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
}
String accessToken = oauthService.refreshAccessToken(refreshTokenCookie.getValue());
refreshTokenResponseDto.setAccessToken(accessToken);
return refreshTokenResponseDto;
}
}
🔒 SecurityConfig
com.epguy.auth.config
Spring Security 설정
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
private final JwtTokenService jwtTokenService;
private final UserService userService;
@Bean
public AuthenticationManager authenticationManager(
final AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain configure(final HttpSecurity http) throws Exception {
return http.cors(withDefaults())
.csrf((csrf) -> csrf.disable())
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/login/**", "/token/refresh").permitAll()
.requestMatchers("/user/**").hasAuthority(UserRole.USER.getRole())
.anyRequest().authenticated())
.sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(httpSecurityFormLoginConfigurer -> httpSecurityFormLoginConfigurer.disable()) // 로그인 폼 미사용
.httpBasic(httpSecurityHttpBasicConfigurer -> httpSecurityHttpBasicConfigurer.disable()) // http basic 미사용
.addFilterBefore(new JwtFilter(jwtTokenService, userService), UsernamePasswordAuthenticationFilter.class) // JWT Filter 추가
.addFilterBefore(new ExceptionHandlerFilter(), JwtFilter.class) // JwtFilter 에서 CustomException 사용하기 위해 추가
.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer(){
// 아래 url은 filter 에서 제외
return web ->
web.ignoring()
.requestMatchers("/login/**", "/token/refresh");
}
}
🔒 JwtTokenService
com.epguy.auth.service
JWT 토큰 생성, 조회 관련 서비스
@Service
public class JwtTokenService implements InitializingBean {
private long accessTokenExpirationInSeconds;
private long refreshTokenExpirationInSeconds;
private final String secretKey;
private static Key key;
public JwtTokenService(
@Value("${jwt.access.token.expiration.seconds}") long accessTokenExpirationInSeconds,
@Value("${jwt.refresh.token.expiration.seconds}") long refreshTokenExpirationInSeconds,
@Value("${jwt.token.secret-key}") String secretKey
) {
this.accessTokenExpirationInSeconds = accessTokenExpirationInSeconds * 1000;
this.refreshTokenExpirationInSeconds = refreshTokenExpirationInSeconds * 1000;
this.secretKey = secretKey;
}
// 빈 주입받은 후 실행되는 메소드
@Override
public void afterPropertiesSet() {
this.key = getKeyFromBase64EncodedKey(encodeBase64SecretKey(secretKey));
}
public String createAccessToken(String payload){
return createToken(payload, accessTokenExpirationInSeconds);
}
public String createRefreshToken(){
byte[] array = new byte[7];
new Random().nextBytes(array);
String generatedString = new String(array, StandardCharsets.UTF_8);
return createToken(generatedString, refreshTokenExpirationInSeconds);
}
public String createToken(String payload, long expireLength){
Claims claims = Jwts.claims().setSubject(payload);
Date now = new Date();
Date validity = new Date(now.getTime() + expireLength);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public String getPayload(String token){
try{
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
}catch (ExpiredJwtException e){
return e.getClaims().getSubject();
}catch (JwtException e){
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
}
public boolean validateToken(String token){
try{
Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
return !claimsJws.getBody().getExpiration().before(new Date());
}catch (JwtException | IllegalArgumentException exception){
return false;
}
}
private String encodeBase64SecretKey(String secretKey) {
return Encoders.BASE64.encode(secretKey.getBytes(StandardCharsets.UTF_8));
}
private Key getKeyFromBase64EncodedKey(String base64EncodedSecretKey) {
byte[] keyBytes = Decoders.BASE64.decode(base64EncodedSecretKey);
Key key = Keys.hmacShaKeyFor(keyBytes);
return key;
}
//클라이언트 쿠키에 리프레시토큰 저장 시켜주는 메소드
public void addRefreshTokenToCookie(String refreshToken, HttpServletResponse response) {
Long age = refreshTokenExpirationInSeconds;
Cookie cookie = new Cookie("refresh_token",refreshToken);
cookie.setPath("/");
cookie.setMaxAge(age.intValue());
cookie.setHttpOnly(true);
response.addCookie(cookie);
}
}
🔒 JwtFilter
com.epguy.auth.filter
Api 요청 올 때마다 액세스토큰이 유효한지 확인 후 SecurityContext에 유저정보 저장하는 Filter.
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
public static final String AUTHORIZATION_HEADER = "Authorization";
private final JwtTokenService jwtTokenService;
private final UserService userService;
// 요 Filter 에서 액세스토큰이 유효한지 확인 후 SecurityContext에 계정정보 저장
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
logger.info("[JwtFilter] : " + httpServletRequest.getRequestURL().toString());
String jwt = resolveToken(httpServletRequest);
if (StringUtils.hasText(jwt) && jwtTokenService.validateToken(jwt)) {
Long userId = Long.valueOf(jwtTokenService.getPayload(jwt)); // 토큰 Payload에 있는 userId 가져오기
UserDto user = userService.findById(userId); // userId로
if(user == null) {
throw new CustomException(ErrorCode.NOT_EXIST_USER);
}
UserDetails userDetails = UserPrincipal.create(user);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
throw new CustomException(ErrorCode.INVALID_ACCESS_TOKEN);
}
filterChain.doFilter(servletRequest, servletResponse);
}
// Header에서 Access Token 가져오기
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
🔒 UserPrincipal
com.epguy.auth.models
SecurityContext 에 유저정보 저장할 때 사용하기 위한 유저모델
@Getter
@AllArgsConstructor
// SecurityContext authentication에 저장될 유저정보
public class UserPrincipal implements UserDetails {
private long id;
private String email;
private String password;
private Collection<? extends GrantedAuthority> authorities;
@Setter
private Map<String, Object> attributes;
public static UserPrincipal create(UserDto user) {
List<GrantedAuthority> authorities =
Collections.singletonList(new SimpleGrantedAuthority(UserRole.USER.getRole()));
return new UserPrincipal(
user.getId(),
user.getEmail(),
"",
authorities,
null
);
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
@Override
public String getUsername() {
return email;
}
}
🔒 UserRole
com.epguy.auth.enums
유저 권한 Enum
public enum UserRole {
USER("user"),
ADMIN("admin");
private final String role;
UserRole(String role) {
this.role = role;
}
public String getRole() {
return role;
}
}
🔒 SecurityUtil
com.epguy.auth.utils
SecurityContext Authentication 에 있는 유저정보를 가져와서 userId 만 추출하는 Util
public class SecurityUtil {
private SecurityUtil() {}
public static long getCurrentUserId() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
long userId;
if (authentication.getPrincipal() instanceof UserPrincipal userPrincipal) {
userId = userPrincipal.getId();
} else {
throw new CustomException(ErrorCode.BAD_REQUEST);
}
return userId;
}
}
🔒 OauthRequestDto
com.epguy.auth.dto
로그인 Api RequestBody
@Getter
public class OauthRequestDto {
private String accessToken;
}
🔒 OauthResponseDto
com.epguy.auth.dto
로그인 Api ResponseBody
@Getter
@Setter
public class OauthResponseDto {
private String accessToken;
}
🔒 RefreshTokenResponseDto
com.epguy.auth.dto
액세스토큰 갱신 Api ResposnseBody
@Getter
@Setter
public class RefreshTokenResponseDto {
private String accessToken;
}
3. 카카오 로그인 구현
💬 KakaoOauthService
com.epguy.auth.service
카카오 AccessToken으로 유저정보 가져와서 DB에 저장하는 서비스
@RequiredArgsConstructor
@Service
public class KakaoOauthService {
private final UserService userService;
// 카카오Api 호출해서 AccessToken으로 유저정보 가져오기
public Map<String, Object> getUserAttributesByToken(String accessToken){
return WebClient.create()
.get()
.uri("https://kapi.kakao.com/v2/user/me")
.headers(httpHeaders -> httpHeaders.setBearerAuth(accessToken))
.retrieve()
.bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() {})
.block();
}
// 카카오API에서 가져온 유저정보를 DB에 저장
public UserDto getUserProfileByToken(String accessToken){
Map<String, Object> userAttributesByToken = getUserAttributesByToken(accessToken);
KakaoInfoDto kakaoInfoDto = new KakaoInfoDto(userAttributesByToken);
UserDto userDto = UserDto.builder()
.id(kakaoInfoDto.getId())
.email(kakaoInfoDto.getEmail())
.platform("kakao")
.build();
if(userService.findById(userDto.getId()) != null) {
userService.update(userDto);
} else {
userService.save(userDto);
}
return userDto;
}
}
💬 OauthService
com.epguy.auth.service
토큰생성,갱신 등 로그인 관련 로직 서비스
@RequiredArgsConstructor
@Service
public class OauthService {
private final UserService userService;
private final JwtTokenService jwtTokenService;
private final KakaoOauthService kakaoOauthService;
//카카오 로그인
public String loginWithKakao(String accessToken, HttpServletResponse response) {
UserDto userDto = kakaoOauthService.getUserProfileByToken(accessToken);
return getTokens(userDto.getId(), response);
}
//액세스토큰, 리프레시토큰 생성
public String getTokens(Long id, HttpServletResponse response) {
final String accessToken = jwtTokenService.createAccessToken(id.toString());
final String refreshToken = jwtTokenService.createRefreshToken();
UserDto userDto = userService.findById(id);
userDto.setRefreshToken(refreshToken);
userService.updateRefreshToken(userDto);
jwtTokenService.addRefreshTokenToCookie(refreshToken, response);
return accessToken;
}
// 리프레시 토큰으로 액세스토큰 새로 갱신
public String refreshAccessToken(String refreshToken) {
UserDto userDto = userService.findByRefreshToken(refreshToken);
if(userDto == null) {
throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
}
if(!jwtTokenService.validateToken(refreshToken)) {
throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
}
return jwtTokenService.createAccessToken(userDto.getId().toString());
}
}
💬 KakaoInfoDto
com.epguy.auth.dto
카카오Api로 정보 가져와서 담을 Dto
@Getter
@AllArgsConstructor
public class KakaoInfoDto {
private Long id;
private String email;
public KakaoInfoDto(Map<String, Object> attributes) {
this.id = Long.valueOf(attributes.get("id").toString());
this.email = attributes.get("email") != null ? attributes.get("email").toString() : "";
}
}
4. 유저 API 구현
😊 UserController
com.epguy.user.controller
유저 정보 조회하는 API 컨트롤러
@RestController
@RequiredArgsConstructor
@RequestMapping("/user")
public class UserController {
private final UserService userService;
// 유저정보 조회 API
@GetMapping("/info")
public UserDto info() {
final long userId = SecurityUtil.getCurrentUserId();
UserDto userDto = userService.findById(userId);
if(userDto == null) {
throw new CustomException(ErrorCode.NOT_EXIST_USER);
}
return userDto;
}
}
😊 UserDto
com.epguy.user.dto
유저 Dto
@Getter
@Setter
@Builder
@AllArgsConstructor
public class UserDto {
private Long id;
private String email;
private String platform;
private String refreshToken;
}
😊 UserMapper
com.epguy.user.mapper
유저 DB Mapper
@Mapper
public interface UserMapper {
void save(UserDto userDto);
UserDto findById(Long id);
UserDto findByRefreshToken(String refreshToken);
void update(UserDto userDto);
void updateRefreshToken(UserDto userDto);
}
😊 UserService
com.epguy.user.service
유저 DB 서비스
@RequiredArgsConstructor
@Service
public class UserService {
private final UserMapper userMapper;
public void save(UserDto userDto) {
userMapper.save(userDto);
}
public UserDto findById(Long id) {
return userMapper.findById(id);
}
public UserDto findByRefreshToken(String refreshToken) {
return userMapper.findByRefreshToken(refreshToken);
}
public void update(UserDto userDto) {
userMapper.update(userDto);
}
public void updateRefreshToken(UserDto userDto) {
userMapper.updateRefreshToken(userDto);
}
}
😊 UserMapper.xml
resources/mapper
유저 DB XML
<mapper namespace="com.lol.duogo.user.mapper.UserMapper">
<select id="findById" resultType="com.lol.duogo.user.dto.UserDto" parameterType="java.lang.Long">
SELECT * FROM tb_user WHERE id=#{id}
</select>
<select id="findByRefreshToken" resultType="com.lol.duogo.user.dto.UserDto" parameterType="java.lang.String">
SELECT * FROM tb_user WHERE refresh_token = #{refreshToken}
</select>
<insert id="save" parameterType="com.lol.duogo.user.dto.UserDto">
INSERT tb_user (id, email, platform, refresh_token) VALUES (#{id}, #{email}, #{platform}, #{refreshToken})
</insert>
<update id="update" parameterType="com.lol.duogo.user.dto.UserDto">
UPDATE tb_user SET email = #{email}, platform = #{platform} WHERE id = #{id}
</update>
<update id="updateRefreshToken" parameterType="com.lol.duogo.user.dto.UserDto">
UPDATE tb_user SET refresh_token = #{refreshToken} WHERE id = #{id}
</update>
</mapper>
5. Exception 핸들링
❗ GlobalExceptionHandler
com.epguy.exception
@ControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(CustomException ex) {
logger.error("[CustomException] errCode : " + ex.getErrorCode());
logger.error("[CustomException] errMsg : " + ex.getMessage());
return new ResponseEntity(
new ErrorResponse(ex.getMessage()),
ex.getErrorCode().getHttpStatus()
);
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ErrorResponse> handleRuntimeException(RuntimeException ex) {
logger.error("[RuntimeException] errMsg : " + ex.getMessage());
return new ResponseEntity(
new ErrorResponse(ex.getMessage()),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(RuntimeException ex) {
logger.error("[Exception] errMsg : " + ex.getMessage());
return new ResponseEntity(
new ErrorResponse(ex.getMessage()),
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
❗ ExceptionHandlerFilter
com.epguy.exception
JwtFilter 내부에서 CustomException 사용 가능하도록 하기위한 Filter.
public class ExceptionHandlerFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (CustomException ex) {
setErrorResponse(ex.getErrorCode().getHttpStatus(), response, ex);
} catch (Exception ex) {
setErrorResponse(HttpStatus.INTERNAL_SERVER_ERROR, response, ex);
}
}
public void setErrorResponse(HttpStatus status, HttpServletResponse response, Throwable ex) throws IOException {
logger.error("[ExceptionHandlerFilter] errMsg : " + ex.getMessage());
response.setStatus(status.value());
response.setContentType("application/json; charset=UTF-8");
response.getWriter().write(
new ErrorResponse(ex.getMessage())
.convertToJson()
);
}
}
❗ ErrorResponse
com.epguy.exception
@Getter
@AllArgsConstructor
public class ErrorResponse {
private static final ObjectMapper objectMapper = new ObjectMapper();
private String errMsg;
public String convertToJson() throws JsonProcessingException {
return objectMapper.writeValueAsString(this);
}
}
❗ ErrorCode
com.epguy.exception
public enum ErrorCode {
UNAUTHORIZED("인증되지않은 요청입니다.", HttpStatus.UNAUTHORIZED),
INVALID_ACCESS_TOKEN("유효하지않은 액세스 토큰입니다.", HttpStatus.UNAUTHORIZED),
INVALID_REFRESH_TOKEN("유효하지않은 리프레시 토큰입니다.", HttpStatus.UNAUTHORIZED),
BAD_REQUEST("잘못된 요청입니다.", HttpStatus.BAD_REQUEST),
NOT_EXIST_USER("존재하지 않는 유저입니다.", HttpStatus.UNAUTHORIZED);
private final String message;
private final HttpStatus httpStatus;
ErrorCode(String message, HttpStatus httpStatus) {
this.message = message;
this.httpStatus = httpStatus;
}
public String getMessage() {
return message;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
}
❗ CustomException
com.epguy.exception
public class CustomException extends RuntimeException {
private final ErrorCode errorCode;
public CustomException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}
'개발 > Java' 카테고리의 다른 글
[FCM] FCM V1 으로 마이그레이션 (0) | 2024.09.11 |
---|---|
[MyBatis] Invalid bound statement (not found) (0) | 2023.09.27 |
JsonMappingException: No suitable constructor found for type 에러 해결법 (0) | 2023.09.26 |