코드 구현 설계
이번에 제가 만들 프로젝트에서는 일반 로그인 (이메일과 비밀번호를 이용한 로그인) 과 소셜로그인 (구글 로그인, 애플 로그인) 각각 두가지 방식으로 로그인을 진행할 수 있게끔 만들 계획입니다.
만약 소셜로그인을 할때는 만약 첫 로그인이라면 사용자 추가 정보 (국적, 나이 등) 를 입력받는 화면을 제공해 회원 정보를 등록하는 플로우로 구현할 예정입니다.
이번 포스팅에선 회원관련 클래스를 구현하고 Jwt 를 이용해 일반로그인(폼 로그인) 회원이 회원가입을하고 로그인까지 하는 과정을 구현해보겠습니다.
아직 JWT 가 무엇인지 잘 정리가 안되신 분들은 이전 포스팅을 한번 보고 오시는걸 추천드립니다!
[Spring] (1) Spring Security + Jwt + Oauth2 구현해보기
먼저 실제 코드를 적용해보기 전 JWT 와 OAuth2 가 무엇인지 이론적으로 간단히 알아봅시다! Jwt (Json Web Token) 이 무엇일까? 클라이언트와 서버가 통신할때 유저를 인증하고 식별하는 토큰이 바로 JW
comumu.tistory.com
프로젝트 설정
🎯 build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-jdbc'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation group: 'com.auth0', name: 'java-jwt', version: '3.16.0'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
jwt, OAuth, jpa 와 관련된 라이브러리를 build.gradle 파일에 추가합니다.
🎯 application.yml
jwt:
secretKey: [secret key]
access:
expiration: 3600000 # 1??(60?) (1000L(ms -> s) * 60L(s -> m) * 60L(m -> h))
header: Authorization
refresh:
expiration: 1209600000 # (1000L(ms -> s) * 60L(s -> m) * 60L(m -> h) * 24L(h -> ??) * 14(2?))
header: Authorization-refresh
jwt 관련 설정입니다.
secretKey 에 들어갈 키는 openssl rand -hex 64 명령어를 터미널에 입력후 나온 값을 사용합니다.
회원 관련 클래스
🎯 User
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
private Long id;
@Enumerated(EnumType.STRING)
private Role role;
@Enumerated(EnumType.STRING)
private SocialType socialType;// GOOGLE, APPLE
private String socialId; // 로그인한 소셜 타입의 식별자 값 (일반 로그인인 경우 null)
private String refreshToken; // 리프레시 토큰
private String email;
private String nickName;
private String profileUrl;
private String password;
public void passwordEncode(PasswordEncoder passwordEncoder) {
this.password = passwordEncoder.encode(this.password);
}
public void updateRefreshToken(String updateRefreshToken) {
this.refreshToken = updateRefreshToken;
}
}
회원 엔티티 입니다.
socialType 필드와 socialId 필드는 다음 포스팅인 OAuth2.0 기능을 구현할때 사용됩니다.
passwordEncode() - 패스워드 인코딩 메서드
updateRefreshToken() - RefreshToken 기간이 만료 됐을때 사용될 메서드
🎯 SocialType
package security.account.domain;
public enum SocialType {
GOOGLE, APPLE
}
소셜로그인 타입을 구분하기 위한 Enum 입니다.
🎯 Role
package security.account.domain;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum Role {
NORMAL("ROLE_NORMAL"),
ADMIN("ROLE_ADMIN");
private final String key;
}
회원 권한은 NORMAL 과 ADMIN 두개로 나눕니다.
🎯 SignupDto
package security.account.dto;
import lombok.AllArgsConstructor;
import lombok.Getter;
import security.account.domain.Role;
@Getter
@AllArgsConstructor
public class SignupDto {
private final String email;
private final String password;
private final String nickName;
private final Integer phoneNumber;
private final String profileUrl;
private final Integer age;
private final Role role;
}
회원가입이 진행될때 사용되는 데이터 전달용 DTO 입니다.
🎯 UserRepository
import org.springframework.data.jpa.repository.JpaRepository;
import security.account.domain.User;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
Optional<User> findByNickName(String nickname);
Optional<User> findByRefreshToken(String refreshToken);
}
jpa 를 이용해 UserRepository 를 구현했습니다.
간단한 코드라 메서드에 대한 설명은 생략하겠습니다.
🎯 UserService
package security.account.service;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import security.account.domain.User;
import security.account.dto.SignupDto;
import security.account.repository.UserRepository;
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private static final String ALREADY_EXIST_EMAIL_ERROR = "이미 존재하는 이메일 입니다.";
private static final String ALREADY_EXIST_NICKNAME_ERROR = "이미 존재하는 닉네임 입니다.";
public void signUp(SignupDto signupDto) throws Exception {
if(userRepository.findByEmail(signupDto.getEmail()).isPresent()){
throw new Exception(ALREADY_EXIST_EMAIL_ERROR);
}
if(userRepository.findByNickName(signupDto.getNickName()).isPresent()){
throw new Exception(ALREADY_EXIST_NICKNAME_ERROR);
}
User user = User.builder()
.email(signupDto.getEmail())
.password(signupDto.getPassword())
.nickName(signupDto.getNickName())
.role(signupDto.getRole())
.build();
user.passwordEncode(passwordEncoder);
userRepository.save(user);
}
}
signUp 메서드는 전달받은 SignupDto의 데이터를 기반으로 사용자 정보를 저장하는 로직입니다.
Spring Security 는 로그인과 관련된 필터만 제공해 회원가입 로직은 직접 만들어야 합니다.
🎯 UserController
package security.account.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import security.account.dto.SignupDto;
import security.account.service.UserService;
@RestController
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// 회원 가입 api
@PostMapping("/sign-up")
public String signUp(@RequestBody SignupDto signupDto) throws Exception {
userService.signUp(signupDto);
return "success";
}
@PostMapping("/test")
public String test(){
return "ok";
}
}
/sign-up 요청이 들어왔을때 회원가입 로직이 실행됩니다.
/test 요청은 테스트용 URL 입니다.
/login 과 같은 로그인 URL 이 왜 없지? 라고 생각할 수 있습니다. Spring Security 는 로그인과 관련된 URL 을 지정해 로직을 실행할 수 있는 필터가 있어 따로 Controller 에서 로그인 요청 URL 을 만들지 않고 추후 Filter 를 이용해 만듭니다. (아래에서 자세히 설명하겠습니다.)
JWT 관련 클래스
🎯 JwtService
package security.account.service;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import security.account.repository.UserRepository;
import java.util.Date;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Getter
@Slf4j
public class JwtService {
/*
application.yml 의 프로퍼티 주입
*/
@Value("${jwt.secretKey}")
private String secretKey;
@Value("${jwt.access.expiration}")
private Long accessTokenExpirationPeriod;
@Value("${jwt.refresh.expiration}")
private Long refreshTokenExpirationPeriod;
@Value("${jwt.access.header}")
private String accessHeader;
@Value("${jwt.refresh.header}")
private String refreshHeader;
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";
private static final String REFRESH_TOKEN_SUBJECT = "RefreshToken";
private static final String EMAIL_CLAIM = "email";
private static final String BEARER = "Bearer ";
private final UserRepository userRepository;
/**
* AccessToken(Jwt) 생성 메소드
*
* AccessToken 에는 날짜와 이메일을 페이로드에 담습니다.
* 사용할 알고리즘은 HMA512 알고리즘이고 application.yml 에서 지정한 secret 키로 암호화
*/
public String createAccessToken(String email) {
Date now = new Date();
return JWT.create()
.withSubject(ACCESS_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod))
.withClaim(EMAIL_CLAIM, email)
.sign(Algorithm.HMAC512(secretKey));
}
/**
* RefreshToken 생성
*
* RefreshToken 은 클레임에 이메일을 넣지 않음
*/
public String createRefreshToken() {
Date now = new Date();
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.sign(Algorithm.HMAC512(secretKey));
}
/**
* AccessToken 헤더에 실어서 보내기
*/
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("재발급된 Access Token : {}", accessToken);
}
/**
* 로그인 시 AccessToken 과 RefreshToken 을 헤더에 실어서 내보냄
*/
public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
response.setStatus(HttpServletResponse.SC_OK);
setAccessTokenHeader(response, accessToken);
setRefreshTokenHeader(response, refreshToken);
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
public Optional<String> extractEmail(String accessToken) {
try {
// AccessToken 의 클레임에서 Email 을 추출하는 기능
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(accessToken)
.getClaim(EMAIL_CLAIM)
.asString());
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return Optional.empty();
}
}
public void setAccessTokenHeader(HttpServletResponse response, String accessToken) {
response.setHeader(accessHeader, accessToken);
}
public void setRefreshTokenHeader(HttpServletResponse response, String refreshToken) {
response.setHeader(refreshHeader, refreshToken);
}
public void updateRefreshToken(String email, String refreshToken) {
userRepository.findByEmail(email)
.ifPresentOrElse(
user -> user.updateRefreshToken(refreshToken),
() -> new Exception("일치하는 회원이 없습니다.")
);
}
// 토큰 유효성을 검사하는 메서드
public boolean validateToken(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
return false;
}
}
}
JwtService 에서 구현된 메서드들은 jwt 토큰을 생성, 추출과 같은 토큰자체를 생성하고 제어하는 메서드들고 구성되어 있습니다.
즉, 토큰의 전반적인 상태를 관리한다고 생각하면 됩니다.
각각의 메서드들이 어떤 역할을 하는지 메서드별로 설명하겠습니다.
createAccessToken 메서드
public String createAccessToken(String email) {
Date now = new Date();
return JWT.create()
.withSubject(ACCESS_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + accessTokenExpirationPeriod))
.withClaim(EMAIL_CLAIM, email)
.sign(Algorithm.HMAC512(secretKey));
}
createAccessToken 메서드는 사용자에게 전달할 AccessToken 을 생성하는 메서드 입니다.
AccessToken 은 Jwt 토큰입니다. Jwt 토큰 구성요소인 클레임(Claim) 에 들어갈 정보인 email 을 .withClaim()을 이용해 집어 넣고, sign() 메섣드를 이용해 사용할 알고리즘과 서버의 시크릿키를 넣어 Jwt 토큰을 암호화해 생성합니다.
createRefreshToken 메서드
public String createRefreshToken() {
Date now = new Date();
return JWT.create()
.withSubject(REFRESH_TOKEN_SUBJECT)
.withExpiresAt(new Date(now.getTime() + refreshTokenExpirationPeriod))
.sign(Algorithm.HMAC512(secretKey));
}
createRefreshToken 메서드는 RefreshToken 을 생성하는 메서드입니다.
RefreshToken 또한 Jwt 토큰입니다. RefreshToken 은 따로 사용자 정보를 담지 않고, 단순히 만료 시간과 암호화를 진행한 후 토큰을 생성하게 구현했습니다.
sendAccessToken 메서드
public void sendAccessToken(HttpServletResponse response, String accessToken) {
response.setStatus(HttpServletResponse.SC_OK);
response.setHeader(accessHeader, accessToken);
log.info("재발급된 Access Token : {}", accessToken);
}
AccessToken 을 재발급할때 사용합니다.
sendAccessAndRefreshToken 메서드
public void sendAccessAndRefreshToken(HttpServletResponse response, String accessToken, String refreshToken) {
response.setStatus(HttpServletResponse.SC_OK);
setAccessTokenHeader(response, accessToken);
setRefreshTokenHeader(response, refreshToken);
log.info("Access Token, Refresh Token 헤더 설정 완료");
}
사용자가 로그인했을때 AccessToken 과 RefreshToken 을 보낼때 사용할 메서드입니다.
extractRefreshToken() && extractAccessToken() && extractEmail()
public Optional<String> extractRefreshToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(refreshHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
public Optional<String> extractAccessToken(HttpServletRequest request) {
return Optional.ofNullable(request.getHeader(accessHeader))
.filter(refreshToken -> refreshToken.startsWith(BEARER))
.map(refreshToken -> refreshToken.replace(BEARER, ""));
}
public Optional<String> extractEmail(String accessToken) {
try {
// AccessToken 의 클레임에서 Email 을 추출하는 기능
return Optional.ofNullable(JWT.require(Algorithm.HMAC512(secretKey))
.build()
.verify(accessToken)
.getClaim(EMAIL_CLAIM)
.asString());
} catch (Exception e) {
log.error("액세스 토큰이 유효하지 않습니다.");
return Optional.empty();
}
}
사용자는 토큰을 보낼때 Header 에 담아 보냅니다. 각각 AccessToken, RefreshToken, Email 을 사용자가 보낸 요청에서 추출할때 사용할 메서드입니다.
updateRefreshToken 메서드
public void updateRefreshToken(String email, String refreshToken) {
userRepository.findByEmail(email)
.ifPresentOrElse(
user -> user.updateRefreshToken(refreshToken),
() -> new Exception("일치하는 회원이 없습니다.")
);
}
RefreshToken 을 DB에 업데이트 하는 메서드입니다.
validateToken 메서드
public boolean validateToken(String token) {
try {
JWT.require(Algorithm.HMAC512(secretKey)).build().verify(token);
return true;
} catch (Exception e) {
log.error("유효하지 않은 토큰입니다. {}", e.getMessage());
return false;
}
}
토큰의 유효성을 검사하는 메서드입니다.
🎯 JwtAuthenticationFilter
package security.account.filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.filter.OncePerRequestFilter;
import security.account.domain.User;
import security.account.repository.UserRepository;
import security.account.service.JwtService;
import java.io.IOException;
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String NO_CHECK_URL = "/login";
private final JwtService jwtService;
private final UserRepository userRepository;
private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response);
return;
}
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::validateToken)
.orElse(null);
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return;
}
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken)
.ifPresent(user -> {
String reIssuedRefreshToken = reIssueRefreshToken(user);
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()),
reIssuedRefreshToken);
});
}
private String reIssueRefreshToken(User user) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
user.updateRefreshToken(reIssuedRefreshToken);
userRepository.saveAndFlush(user);
return reIssuedRefreshToken;
}
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtService.extractAccessToken(request)
.filter(jwtService::validateToken)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
public void saveAuthentication(User myUser) {
String password = myUser.getPassword();
UserDetails userDetailsUser = org.springframework.security.core.userdetails.User.builder()
.username(myUser.getEmail())
.password(password)
.roles(myUser.getRole().name())
.build();
Authentication authentication =
new UsernamePasswordAuthenticationToken(userDetailsUser, null,
authoritiesMapper.mapAuthorities(userDetailsUser.getAuthorities()));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
JwtAuthenticationFilter 는 OncePerRequestFilter 를 상속받아 인증 로직을 구현했고, 해당 필터는 / login 이외의 요청이 들어왔을때 토큰들의 유효성을 검사하는 동작과정을 가지고 있습니다.
즉, JwtAythenticationFilter 에서는 토큰들을 검증해서 인증 처리, 실패, 재발급등의 역할을 수행하는 역할을 담당합니다.
doFilterInternal 메서드
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
if (request.getRequestURI().equals(NO_CHECK_URL)) {
filterChain.doFilter(request, response); // /login 요청이 들어올 경우 필터 로직 종료
return;
}
String refreshToken = jwtService.extractRefreshToken(request)
.filter(jwtService::validateToken)
.orElse(null);
// 사용자 요청 헤더 안에 RefreshToken이 존재한다면 AccessToken이 만료됐다 판단.
if (refreshToken != null) {
checkRefreshTokenAndReIssueAccessToken(response, refreshToken);
return;
}
// 사용자 요청 헤더 안에 RefreshToken이 없으면 AccessToken 인증
if (refreshToken == null) {
checkAccessTokenAndAuthentication(request, response, filterChain);
}
}
사용자 요청을 HttpServletRequest 로 받아와 RefreshToken 을 추출합니다. RefreshToken 이 사용자 요청 헤더에 존재한다면 AccessToken이 만료되어 RefreshToken 을 보내 재발급을 요청한 상황이고,
RefreshToekn 이 존재하지 않으면 AccessToken 의 유효성을 검증하는 로직을 실행합니다.
checkRefreshTokenAndReIssueAccessToken 메서드
public void checkRefreshTokenAndReIssueAccessToken(HttpServletResponse response, String refreshToken) {
userRepository.findByRefreshToken(refreshToken)
.ifPresent(user -> {
String reIssuedRefreshToken = reIssueRefreshToken(user);
jwtService.sendAccessAndRefreshToken(response, jwtService.createAccessToken(user.getEmail()),
reIssuedRefreshToken);
});
}
RefreshToken 을 재발급하기 위해 DB 에서 RefreshToken 을 기반으로 사용자를 찾고 AccessToken 과 RefreshToken 을 둘다 재발급 받아 사용자 응답에 두 토큰을 보냅니다.
reIssueRefreshToken 메서드
private String reIssueRefreshToken(User user) {
String reIssuedRefreshToken = jwtService.createRefreshToken();
user.updateRefreshToken(reIssuedRefreshToken);
userRepository.saveAndFlush(user);
return reIssuedRefreshToken;
}
RefreshToken 을 재발급받고 DB User 테이블의 RefreshToken 필드를 업데이트하는 메서드입니다.
checkAccessTokenAndAuthentication 메서드
public void checkAccessTokenAndAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
log.info("checkAccessTokenAndAuthentication() 호출");
jwtService.extractAccessToken(request)
.filter(jwtService::validateToken)
.ifPresent(accessToken -> jwtService.extractEmail(accessToken)
.ifPresent(email -> userRepository.findByEmail(email)
.ifPresent(this::saveAuthentication)));
filterChain.doFilter(request, response);
}
AccessToken 을 검증하는 메서드 입니다. 사용자 요청에서 AccessToken 을 추출해 검증하고, 만약 검증을 통과하면 AccessToken 에서 이메일을 추출해서 사용자를 찾고 saveAuthentication 메서드를 호출해 인증처리를 합니다.
🎯 CustomLoginAuthenticationFilter
package security.account.filter;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.StreamUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
public class CustomLoginAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/login";
private static final String HTTP_METHOD = "POST";
private static final String CONTENT_TYPE = "application/json";
private static final String USERNAME_KEY = "email";
private static final String PASSWORD_KEY = "password";
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER = new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
private final ObjectMapper objectMapper;
/**
* 부모 클래스인 AbstractAuthenticationProcessingFilter 의 생성자 파라미터로 위에서 선언한 /login URL 을 설정해
* /login 로 요청이 들어왔을때 해당 필터가 동작함
*/
public CustomLoginAuthenticationFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
this.objectMapper = objectMapper;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, IOException {
// application/json 형식이 아니면 예외 발생
if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
//요청받은 messageBody 는 json 형식이므로 ObjectMapper 로 email 과 password 를 추출한다.
String email = usernamePasswordMap.get(USERNAME_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY);
//인증 처리 대상이 될 UsernamePasswordAuthenticationToken 객체를 email 과 password 로 만든다.
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);//principal 과 credentials 전달
// attemptAuthentication 메서드에서 인증 처리 객체를 반환하면 LoginService 의 loadUserByUsername 이 동작한다.
return this.getAuthenticationManager().authenticate(authRequest);
}
}
CustomLoginAuthentictionFilter 클래스는 /login 요청이 들어왔을때 로그인 로직을 처리하는 역할을 담당하고 있습니다.
public CustomLoginAuthenticationFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
this.objectMapper = objectMapper;
}
생성자에서 AbstractAuthenticationProcessingFilter 추상 클래스를 상속받아 생성자를 주입해 /login 요청이 들어왔을때 로직이 실행될 수 있습니다.
attemptAuthentication 메서드
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, IOException {
// application/json 형식이 아니면 예외 발생
if(request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE) ) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
String messageBody = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
Map<String, String> usernamePasswordMap = objectMapper.readValue(messageBody, Map.class);
//요청받은 messageBody 는 json 형식이므로 ObjectMapper 로 email 과 password 를 추출한다.
String email = usernamePasswordMap.get(USERNAME_KEY);
String password = usernamePasswordMap.get(PASSWORD_KEY);
//인증 처리 대상이 될 UsernamePasswordAuthenticationToken 객체를 email 과 password 로 만든다.
UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(email, password);//principal 과 credentials 전달
// attemptAuthentication 메서드에서 인증 처리 객체를 반환하면 LoginService 의 loadUserByUsername 이 동작한다.
return this.getAuthenticationManager().authenticate(authRequest);
}
attemptAuthentication 메서드를 오버라이드해 /login 요청이 들어왔을때 인증처리 객체를 만들어 반환합니다.
🎯 LoginService
package security.account.service;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import security.account.domain.User;
import security.account.repository.UserRepository;
@Service
@RequiredArgsConstructor
public class LoginService implements UserDetailsService{
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("해당 이메일이 존재하지 않습니다."));
return org.springframework.security.core.userdetails.User.builder()
.username(user.getEmail())
.password(user.getPassword())
.roles(user.getRole().name())
.build();
}
}
DB에서 찾은 사용자 정보를 기반으로 org.springframework.security.core.userdetails.User.builder() 객체를 만들어 반환합니다. Spring Security 에는 기본적으로 동작하는 순서와 반환해야하는 객체, 실행하는 메서드가 각각 정해져 있기때문에 Spring Security 를 사용한다면 제공하는 순서와 반환값들에 맞춰 코드를 구현해야 합니다.
(Spring Security 동작과정은 아래에서 설명하겠습니다.)
🎯 LoginSuccessHandler
package security.account.handler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import security.account.repository.UserRepository;
import security.account.service.JwtService;
@Slf4j
@RequiredArgsConstructor
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private final JwtService jwtService;
private final UserRepository userRepository;
@Value("${jwt.access.expiration}")
private String accessTokenExpiration;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) {
String email = extractUsername(authentication); // 인증 정보에서 Username(email) 추출
String accessToken = jwtService.createAccessToken(email); // JwtService의 createAccessToken을 사용하여 AccessToken 발급
String refreshToken = jwtService.createRefreshToken(); // JwtService의 createRefreshToken을 사용하여 RefreshToken 발급
jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken); // 응답 헤더에 AccessToken, RefreshToken 실어서 응답
userRepository.findByEmail(email)
.ifPresent(user -> {
user.updateRefreshToken(refreshToken);
userRepository.saveAndFlush(user);
});
log.info("로그인에 성공하였습니다. 이메일 : {}", email);
log.info("로그인에 성공하였습니다. AccessToken : {}", accessToken);
log.info("발급된 AccessToken 만료 기간 : {}", accessTokenExpiration);
}
private String extractUsername(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
return userDetails.getUsername();
}
}
로그인 필터를 거쳐 정상 처리 됐을때 로그인이 성공 했을때 응답을 보내기 위한 역할을 담당합니다.
로그인이 성공했기 때문에 내부에서 AccessToken 과 RefreshToken 을 생성해 Reponse 에 담아 보내줍니다.
🎯 LoginFailureHandler
package security.account.handler;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import java.io.IOException;
@Slf4j
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.setCharacterEncoding("UTF-8");
response.setContentType("text/plain;charset=UTF-8");
response.getWriter().write("로그인 실패! 이메일이나 비밀번호를 확인해주세요.");
log.info("로그인에 실패했습니다. 메시지 : {}", exception.getMessage());
}
}
로그인을 실패 했을때 Response 에 에러를 담아 보내주는 역할을 담당합니다.
🎯 SecurityConfig
package security.account.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import security.account.filter.CustomLoginAuthenticationFilter;
import security.account.filter.JwtAuthenticationFilter;
import security.account.handler.LoginFailureHandler;
import security.account.handler.LoginSuccessHandler;
import security.account.repository.UserRepository;
import security.account.service.JwtService;
import security.account.service.LoginService;
/**
* 인증은 CustomJsonUsernamePasswordAuthenticationFilter에서 authenticate()로 인증된 사용자로 처리
* JwtAuthenticationProcessingFilter는 AccessToken, RefreshToken 재발급
*/
@Configuration
@EnableWebSecurity // @EnableWebSecurity 어노테이션을 붙여야 Spring Security 기능을 사용할 수 있다.
@RequiredArgsConstructor
public class SecurityConfig {
private final LoginService loginService;
private final JwtService jwtService;
private final UserRepository userRepository;
private final ObjectMapper objectMapper;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin((formLogin) -> formLogin.disable()) // Spring Security 에서는 아무 설정을 하지 않으면 기본 FormLogin 형식의 로그인을 제공한다
.httpBasic((httpBasic) -> httpBasic.disable()) // JWT 토큰을 사용한 로그인 방식이기 때문에 httpBasic disable 로 설정
.csrf((csrfConfig) -> csrfConfig.disable()) // 서버에 인증 정보를 저장하지 않고, 요청 시 인증 정보를 담아서 요청 하므로 보안 기능인 csrf 불필요
.httpBasic((httpBasic) -> httpBasic.disable())
.headers((headerConfig) ->
headerConfig.frameOptions(frameOptionsConfig ->
frameOptionsConfig.disable()
)
)
// 세션 사용 x
.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/sign-up").permitAll()
.anyRequest().authenticated()
);
// 일반 로그인 시 customJsonUsernamePasswordAuthenticationFilter 동작
/**
* addFilterAfter(A, B) B 필터 이후에 A 필터 동작
* 원래 스프링 시큐리티 필터 동작 순서가 LogoutFilter 이후에 로그인 동작 필터가 동작한다.
*
* addFilterBefore(A, B) B 필터 이잔에 A 필터 동작
* Json 로그인 필터가 동작하기전 JWT 인증 필터 동작
*
* LogoutFilter -> jwtAuthenticationFilter -> customJsonUsernamePasswordAuthenticationFilter
*/
http.addFilterAfter(customLoginAuthenticationFilter(), LogoutFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), CustomLoginAuthenticationFilter.class);
return http.build();
}
@Bean
public CustomLoginAuthenticationFilter customLoginAuthenticationFilter() {
//CustomJsonUsernamePasswordAuthenticationFilter 에서 인증할 객체(Authentication) 생성
CustomLoginAuthenticationFilter customJsonUsernamePasswordLoginFilter
= new CustomLoginAuthenticationFilter(objectMapper);
//일반 로그인 인증 로직
customJsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
customJsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessHandler());
customJsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
return customJsonUsernamePasswordLoginFilter;
}
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
//비밀번호 인코딩
provider.setPasswordEncoder(passwordEncoder());
//loginService loadUserByUsername 호출 이때 DaoAuthenticationProvider 가 username 을 꺼내서 loadUserByUsername 을 호출
provider.setUserDetailsService(loginService);
// loadUserByUsername 에서 전달받은 UserDetails 에서 password를 추출해 내부의 PasswordEncoder 에서 password 가 일치하는지 검증을 수행
return new ProviderManager(provider);
}
// jwt 인증필터 빈 등록
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(jwtService, userRepository);
return jwtAuthenticationFilter;
}
// 로그인 성공 시 호출되는 LoginSuccessHandler 빈 등록
@Bean
public LoginSuccessHandler loginSuccessHandler() {
return new LoginSuccessHandler(jwtService, userRepository);
}
// 로그인 실패 시 호출되는 LoginFailureHandler 빈 등록
@Bean
public LoginFailureHandler loginFailureHandler() {
return new LoginFailureHandler();
}
// 패스워드 인코딩을 위한 기능 빈 등록
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
SecurityConfig 는 SpringSecurity 는 위에 구현한 여러 필터들을 설정하는 설정 파일 이라고 생각하면 됩니다. Spring 이 실행될때 SecurityConfig 파일을 우선적으로 찾고 필터들이 어떻게 설정됐는지를 확인합니다. 이를 위해서 @EnableWebSecurity 애노테이션을 클래스 위에 추가해 Spring Security 설정 파일임을 알려줘야 합니다.
filterChain 메서드
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.formLogin((formLogin) -> formLogin.disable()) // Spring Security 에서는 아무 설정을 하지 않으면 기본 FormLogin 형식의 로그인을 제공한다
.httpBasic((httpBasic) -> httpBasic.disable()) // JWT 토큰을 사용한 로그인 방식이기 때문에 httpBasic disable 로 설정
.csrf((csrfConfig) -> csrfConfig.disable()) // 서버에 인증 정보를 저장하지 않고, 요청 시 인증 정보를 담아서 요청 하므로 보안 기능인 csrf 불필요
.httpBasic((httpBasic) -> httpBasic.disable())
.headers((headerConfig) ->
headerConfig.frameOptions(frameOptionsConfig ->
frameOptionsConfig.disable()
)
)
// 세션 사용 x
.sessionManagement((sessionManagement) -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/sign-up").permitAll() // /sign-up 요청은 권한이 없어도 접근 가능, 이외의 요청들은 권한이 필요함
.anyRequest().authenticated()
);
/**
* addFilterAfter(A, B) B 필터 이후에 A 필터 동작
* 원래 스프링 시큐리티 필터 동작 순서가 LogoutFilter 이후에 로그인 동작 필터가 동작한다.
*
* addFilterBefore(A, B) B 필터 이잔에 A 필터 동작
* Json 로그인 필터가 동작하기전 JWT 인증 필터 동작
*
* LogoutFilter -> jwtAuthenticationFilter -> customJsonUsernamePasswordAuthenticationFilter
*/
http.addFilterAfter(customLoginAuthenticationFilter(), LogoutFilter.class);
http.addFilterBefore(jwtAuthenticationFilter(), CustomLoginAuthenticationFilter.class);
return http.build();
}
Security Config 클래스에서 가장 중요한 메서드라 생각됩니다. filterChain 을 스프링 빈으로 등록해 Spring Security 동작과정을 Spring에 알려줍니다.
권한 URL 설정과, 일반 로그인(폼 로그인)시 어떤 필터가 동작할지, Jwt 인증 로그인 필터는 무엇을 사용할건지 설정했습니다. 자세한 설명은 주석에 달아놨습니다.
customLoginAuthenticationFilter 메서드
@Bean
public CustomLoginAuthenticationFilter customLoginAuthenticationFilter() {
//CustomJsonUsernamePasswordAuthenticationFilter 에서 인증할 객체(Authentication) 생성
CustomLoginAuthenticationFilter customJsonUsernamePasswordLoginFilter
= new CustomLoginAuthenticationFilter(objectMapper);
//일반 로그인 인증 로직
customJsonUsernamePasswordLoginFilter.setAuthenticationManager(authenticationManager());
customJsonUsernamePasswordLoginFilter.setAuthenticationSuccessHandler(loginSuccessHandler());
customJsonUsernamePasswordLoginFilter.setAuthenticationFailureHandler(loginFailureHandler());
return customJsonUsernamePasswordLoginFilter;
}
CustomLoginAuthenticationFilter 는 /login 요청이 들어왔을때 로그인 요청을 처리하는 클래스입니다.
CustomLoginAuthenticationFilter customJsonUsernamePasswordLoginFilter
= new CustomLoginAuthenticationFilter(objectMapper);
이 부분은 요청받은 정보를 기반으로 인증할 객체(Authentication)를 생성합니다.
생성한 객체는 authenticationManager() 메서드를 호출해 DaoAuthenticationProvider 를 만들어 입력받은 username, password 가 DB에 존재하고 일치하는지를 확인합니다.
인증 로직 결과에 따라 성공했을때는 loginSuccessHandler() 를 실패 했을때는 loginFailureHandler() 를 호출합니다.
이외의 아래 코드들은 각각 필터들을 Spring Bean 으로 등록하는 코드입니다.
테스트
🎯 회원가입 테스트
SignUpDto 형식에 맞춰 회원 데이터를 전송합니다.
DB에 값이 정상적으로 잘 들어온걸 확인할 수 있습니다. refresh_token 은 아직 로그인을 하지 않았기 때문에 null 입니다. (social_id, social_type 은 소셜로그인이 아닌 일반 폼 로그인이기 때문에 값이 null 입니다.)
🎯 로그인 테스트
회원 이메일과 비밀번호를 Json 형식으로 담고 로그인 요청을 보내면 응답 Header 에 AccessToken 과 RefreshToken 이 들어온걸 확인할 수 있습니다.
이제 클라이언트는 요청을 보낼때 AccessToken 을 Header에 담아 보내고 만약 AccessToken 이 만료 됐을때 RefreshToken 을 보내 AccessToken 을 재 발급받으면 됩니다!
'Spring > Spring' 카테고리의 다른 글
[Spring] 연관관계를 포함한 객체 MapStruct 사용법 (0) | 2024.04.17 |
---|---|
[Spring] Spring Security + OAuth2.0 + Jwt 예제 구현 (0) | 2024.03.29 |
[Spring] Jwt 와 OAuth2.0 간단하게 알아보기 (0) | 2024.03.22 |
[Spring] Spring AOP 에 대해 알아보자 (0) | 2024.03.20 |
[Spring] JDK 동적 프록시을 알아보자 (0) | 2024.03.13 |