Spring Security
에서 Form Login을 구현을 해보았다.[Spring Boot] Spring Security - Form Login 구현
Spring Security
에서 기본으로 제공하는 Form Login에서 로그인 요청의 기본 흐름이다.Spring Security
에서는 모든 요청을 DispatcherServlet
으로 보내지 않고 인증과 인가를 수행하는 Filter
들의 집한, FilterChain
을 먼저 거치게 된다.UsernamePasswordAuthenticationFilter
라는 이름의 필터에서 로그인 관련 로직을 수행한다. 입력한 username
과 password
를 가지고 UsernamePasswordAuthenticationToken
을 만들어 AuthenticationManager
로 전달한다.AuthenticationManager
에서는 username
과 password
를 가지고 해당 멤버가 존재하는지, 비밀번호를 옳게 입력했는지 등에 관한 인가 작업을 진행한다. 최종으로 인가 승인이 나면 Authentication
라는 객체를 생성하여 SecurityContext
에 저장한다.AuthenticationManager
에서 username
으로 해당 멤버가 존재하는지 파악할 필요가 있다. 이때 어떤 엔티티에서 어떤 로직으로 찾을것인지 커스텀하는 서비스 클래스가 바로 UserDetailsService
이다.@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
return memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Member Not Found"));
}
}
UserDetails
는 자신이 사용할 Member(혹은 User)엔티티에 username, pssword, Authorization 등의 필드를 추가하여 폼로그인을 지원하도록 하는 인터페이스이다. UserDetails
를 implement하고 메서드를 Override하여 구현 가능하다.@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long memberId;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password", nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private Role role;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private Type type;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(Role.USER.toString()));
}
@Override
public String getUsername() {
return email;
}
@Override
public String getPassword() {
return password;
}
}
AuthenticationManger
에서 인증이 완료되면 Authentication
객체를 반환하여 SecurityContext
에 저장한다.SecurityContext
는 현재 인증된 사용자와 관련된 모든 정보를 저장하고 있다. SecurityContextHolder
를 통해 접근 가능하며, Authentication
객체를 저장하고 이를 통해 인증된 사용자 정보를 제공한다. 즉, 서버 하나에 SecurityContext
하나가 존재한다는 것이다. 이는 각 사용자마다의 Authentication
에 따라 설정하고 해제하는 과정에서 성능의 저하가 이루어질 수 있다.Spring Security
의 Form Login을 보완하기 위해 JWT인증 방식을 통해 세션을 사용하지 않고 Form Login을 구현 가능하다.Stateless
인증을 가능하게 하므로 Stateful
인증을 사용하는 세션의 대안으로 사용된다.다음에서 Decoded부분을 보면 header, payload, signature 세 부분으로 나뉜 것을 볼 수 있다.
해당 정보에서 전하고자 하는 정보를 담아 보내면 수신측에서 이를 decode하여 확인 가능하다.
header에서 알고리즘 서명(alg)은 HS256, 토큰 타입(typ)은 JWT이다.
payload에서는 전달하고자 하는 값을 담는다.
your-256-bit-secret
부분에 유저가 설정한 스트링 형태의 코드를 입력하고 해싱하여 신뢰할 수 있는 발급자에 의해 생성되었는지 확인하는데 사용된다.
주어진 사이트에서 생성한 정보를 인코딩하여 JWT토큰을 생성 가능하다.
이때 인코딩한 JWT토큰을 분석하여 검증된 토큰임을 확인하면 Signature Verified를 보여준다.
다음은 JWT의 검증 요소이다.
private String createAccessToken(MemberInfoDto memberInfoDto, Date expirationTime) {
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject("AccessToken")
.setIssuedAt(new Date())
.setExpiration(expirationTime)
.claim("memberId", memberInfoDto.getMemberId())
.claim("email", memberInfoDto.getEmail())
.claim("role", memberInfoDto.getRole())
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
Spring Boot에서 jwt 라이브러리를 사용해 JWT 토큰을 생성한 예시이다. builder()
패턴을 사용하여 이와 같은 방식으로 JWT 토큰을 생성할 예정이다.
jwt의 버전에 따라 JWT를 빌드하는 메서드가 달라질 수 있다.
//jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
build.gradle
에 위와 같은 버전으로 JWT 인증을 구현하였다.accessToken
이라고 부르겠다. 우리는 로그인 시 accessToken
을 HTTP헤더에 담아 보내 인증하게 된다. 이렇게 서버는 전달받은 accessToken을 decode하여 payload부분의 claims를 사용해 accessToken의 주인을 판별한다. 이때 claims는 사용자를 식별할 수 있는 claim이어야 한다.또한, accessToken의 탈취 위험이 있다. 로그인과 별개로, accessToken만 가지고 있으면 서버는 탈취자를 탈취당한 사람으로 인식하게 된다.
accessToken
과 refreshToken
두 개를 받는다.accessToken
의 유효시간 동안 인증된 사용자이며, 유효시간이 지나면 인증되지 않은 사용자로 전환된다.accessToken
이 아닌 refreshToken
을 입력하여 요청을 보낸다.accessToken
이 아니라 refreshToken
이 왔음을 확인 후 아래와 같은 행동을 취한다. (혹은 인증 만료된 accessToken
을 전송하여 인증 만료되었음을 확인 후 accessToken
을 재발급하는 api를 사용하는 방법도 존재한다.)refreshToken
만료 X 시 : accessToken
을 재발급하여 클라이언트로 전송refreshToken
만료 시 : 이때는 다시 로그인을 진행하여 accessToken
과 refreshToken
을 다시 발급받아야 한다.url | method | discription |
---|---|---|
/api/v1/auth/signup | POST | 회원가입 성공 시 "Sign Up Success” 반환 |
/api/v1/auth/login | POST | 로그인 성공 시 JwtInfoDto 반환 (accessToken, refreshToken) |
/api/v1/jwt/reissue | POST | refresh토큰을 전송하여 새로운 accessToken 생성 (JwtInfoDto 반환) |
UsernamePasswordAuthenticationFilter
에서의 인증 대신 JWT를 사용하여 인증을 진행하는 커스텀 필터인 JwtAuthFilter
를 생성하여 Filter로 사용해야 한다.JwtAuthFilter
에서 헤더에 담긴 accessToken
을 파싱하여 유효성 검증 후, accessToken
으로 Authentication
객체를 생성하여 인증정보를 관리하는 SecurityContext
에 넣어 인증을 완료한다.UsernamePasswordAuthenticationFilter
에서 Authentication
객체를 SecurityContext
에 넣는 역할을 하였다.JwtAuthenticationFilter
에 위임했다고 보면 될 것 같다.Member
에 UserDetails
를 implement하여 Member
엔티티 내에서 Authorities와 username, password를 다루게 하였다. 이렇게 했더니 Member
엔티티가 가지는 책임이 데이터베이스와의 상호작용과 보안 관련 역할까지 가지게 되었다. 이는 객체 지향에서 단일 책임 원칙에 위배된다 생각하여 UserDetails
를 CustomUserDetails
로 구현하여 MemberInfoDto
라는 dto 클래스를 메서드 필드로 선언하여 사용하기로 결정하였다.@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "member_id")
private Long memberId;
@Column(name = "email", nullable = false, unique = true)
private String email;
@Column(name = "password", nullable = false)
private String password;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private Role role;
@Enumerated(EnumType.STRING)
@Column(name = "type", nullable = false)
private Type type;
public MemberInfoDto toMemberInfoDto() {
return MemberInfoDto.builder()
.memberId(getMemberId())
.email(getEmail())
.password(getPassword())
.role(getRole())
.build();
}
}
@Getter
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final MemberInfoDto memberInfoDto;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority(Role.USER.toString()));
}
@Override
public String getPassword() {
return memberInfoDto.getPassword();
}
@Override
public String getUsername() {
return memberInfoDto.getEmail();
}
}
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class MemberInfoDto {
private Long memberId;
private String email;
private String password;
private Role role;
}
MemberInfoDto
를 생성하여 이를 CustomUserDetails
에서 사용하도록 하였다.@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Member Not Found"));
MemberInfoDto memberInfoDto = member.toMemberInfoDto();
return new CustomUserDetails(memberInfoDto);
}
}
email
로 member를 찾아 반환한다. 인증 관련해서는 CustomUserDetails
를 사용하므로 member를 MemberInfoDto
로 변경해야한다.grantType
, accessToken
, refreshToken
과 각각의 만료 시간을 보여준다.@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class JwtInfoDto {
private String grantType;
private String accessToken;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private Date accessTokenExpireTime;
private String refreshToken;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private Date refreshTokenExpireTime;
}
JsonFormat
애노테이션을 사용하여 Date
의 데이터 포맷을 String
으로 변경한다.@Slf4j
@Component
public class JwtUtil {
private final Key key;
private final Long accessTokenExpireTime;
private final Long refreshTokenExpireTime;
public JwtUtil(@Value("${jwt.secret}") String secret,
@Value("${jwt.access_expiration_time}") Long accessTokenExpireTime,
@Value("${jwt.refresh_expiration_time}") Long refreshTokenExpireTime) {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenExpireTime = accessTokenExpireTime;
this.refreshTokenExpireTime = refreshTokenExpireTime;
}
/**
* accessToken, refreshToken을 생성한다.
* 컴포넌트 내의 createAccessToken, createRefreshToken을 호출하여 생성한다.
* @param memberInfoDto
* @return JwtInfoDto
*/
public JwtInfoDto createToken(MemberInfoDto memberInfoDto) {
Date accessTokenExpirationTime = new Date(currentTimeMillis() + accessTokenExpireTime);
Date refreshTokenExpirationTime = new Date(currentTimeMillis() + refreshTokenExpireTime);
String accessToken = createAccessToken(memberInfoDto, accessTokenExpirationTime);
String refreshToken = createRefreshToken(memberInfoDto, refreshTokenExpirationTime);
return JwtInfoDto.builder()
.grantType("Bearer")
.accessToken(accessToken)
.accessTokenExpireTime(accessTokenExpirationTime)
.refreshToken(refreshToken)
.refreshTokenExpireTime(refreshTokenExpirationTime)
.build();
}
/**
* accessToken 생성
* @param memberInfoDto
* @param expirationTime
* @return accessToken
*/
private String createAccessToken(MemberInfoDto memberInfoDto, Date expirationTime) {
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject("AccessToken")
.setIssuedAt(new Date())
.setExpiration(expirationTime)
.claim("memberId", memberInfoDto.getMemberId())
.claim("email", memberInfoDto.getEmail())
.claim("role", memberInfoDto.getRole())
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* refreshToken 생성
* @param memberInfoDto
* @param expirationTime
* @return
*/
private String createRefreshToken(MemberInfoDto memberInfoDto, Date expirationTime) {
return Jwts.builder()
.setHeaderParam("typ", "JWT")
.setSubject("RefreshToken")
.setIssuedAt(new Date())
.setExpiration(expirationTime)
.claim("memberId", memberInfoDto.getMemberId())
.claim("email", memberInfoDto.getEmail())
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* token을 파싱하여 email을 리턴
* @param token
* @return email
*/
public String getEmail(String token) {
return parseClaims(token).get("email", String.class);
}
/**
* 해당 token이 유효한지 체크
* @param token
* @return
*/
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("JWT 토큰이 유효하지 않습니다.", e);
} catch (ExpiredJwtException e) {
log.info("JWT 토큰이 만료되었습니다.", e);
} catch (UnsupportedJwtException e) {
log.info("지원하지 않는 JWT 토큰 입니다.", e);
} catch (IllegalArgumentException e) {
log.info("JWT claims가 비어있습니다.", e);
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JwtUtil
생성자에서 설정파일에서 바인딩해놓은 secret
와 access_expiration_time
, refresh_expiration_time
을 사용한다. 이때 secret으로 JWT signature에 사용할 비밀 키로 변환한다. 해당 문자열을 BASE64 형식으로 디코딩한 후, Keys.hmacShaKeyFor(keyBytes)
를 사용하여 Key
클래스의 비밀 키를 생성한다.createToken
: MemberInfoDto
를 파라미터로 받아 accessToken
, refreshToken
을 생성한다.createAccessToken
: MemberInfoDto
, expirationTime
을 파라미터로 받는다..setHeaderParam("typ", "JWT")
.setSubject("AccessToken")
.setIssuedAt(new Date())
.setExpiration(expirationTime)
다음과 같이 등록된 claims를 넣으며,
.claim("memberId", memberInfoDto.getMemberId())
.claim("email", memberInfoDto.getEmail())
.claim("role", memberInfoDto.getRole())
이와 같이 등록되지 않은 claims를 등록한다. 이는 해당 토큰으로 member를 식별하기 위함이다.
.signWith(key, SignatureAlgorithm.HS256)
JwtUtil
생성자를 통해 생성한 key
로 어떤 알고리즘으로 JWT를 생성했는지 서명을 진행한다.
createRefreshToken
: accessToken
의 생성과 비슷하다. 만료시간을 길게 설정해 주어야 한다.getEmail
: toke n
으로 email
을 추출한다. parseClaims
를 사용한다.validateToken
: 해당 token
이 올바른지 체크한다.parseClaims
: token
을 파싱하여 등록해놓은 claims들을 가져올 수 있다.Authentication
객체를 생성하여 SecurityContext
에 Authentication
을 넣어 인증을 완료한다.@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final CustomUserDetailsService customUserDetailsService;
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if(authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) {
String token = authorizationHeader.substring(7);
if(jwtUtil.validateToken(token)) {
String email = jwtUtil.getEmail(token);
UserDetails userDetails = customUserDetailsService.loadUserByUsername(email);
if(userDetails != null) {
Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails.getUsername(), null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
}
filterChain.doFilter(request, response);
}
}
accessToken
을 Authorization
이라는 이름의 헤더에 Bearer ${accessToken}
형식으로 넣어야 한다.OncePerRequestFilter
를 extends하여 해당 필터가 한번만 요청되도록 한다.doFilterInternal
에서 request를 파싱하여 accessToken
을 추출한 후, 유효성 검사를 진행한다.Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails.getUsername(), null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
UsernamePasswordAuthenticaionToken
이라는 Authentication
객체를 생성한다.SecurityContext
에 넣어 인증을 완료한다.@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/auth")
public class AuthApiController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<String> signup(@RequestBody SignUpRequestDto signUpRequestDto) {
authService.signup(signUpRequestDto);
return ResponseEntity.ok("Sign Up Success!");
}
@PostMapping("/login")
public ResponseEntity<JwtInfoDto> login(@RequestBody LoginRequestDto loginRequestDto) {
JwtInfoDto jwtInfoDto = authService.login(loginRequestDto);
return ResponseEntity.ok(jwtInfoDto);
}
}
Sign Up Success!
를 반환한다.jwtInfoDto
를 반환한다.@Service
@RequiredArgsConstructor
public class AuthService {
private final PasswordEncoder passwordEncoder;
private final MemberRepository memberRepository;
private final JwtUtil jwtUtil;
public void signup(SignUpRequestDto signUpRequestDto) {
String encodedPassword = passwordEncoder.encode(signUpRequestDto.getPassword());
Member member = signUpRequestDto.toEntity(encodedPassword);
memberRepository.save(member);
}
public JwtInfoDto login(LoginRequestDto loginRequestDto) {
String email = loginRequestDto.getEmail();
String password = loginRequestDto.getPassword();
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("Member Not Found"));
if(!passwordEncoder.matches(password, member.getPassword())) {
throw new BadCredentialsException("Not Matches password");
}
MemberInfoDto memberInfoDto = member.toMemberInfoDto();
return jwtUtil.createToken(memberInfoDto);
}
}
signup
: 받아온 password를 인코딩하여 member 테이블에 save한다.login
:UsernameNotFoundException
을 반환한다.BadCredentialsException
를 반환한다.MemberInfoDto
를 생성하여 createToken
을 호출한다.refreshToken
으로 만료된 accessToken
을 새로 생성하는 api를 위한 controller이다.@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/jwt")
public class JwtController {
private final JwtService jwtService;
@PostMapping("/reissue")
public ResponseEntity<AccessTokenDto> reissueAccessToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
String refreshToken = header.substring(7);
AccessTokenDto accessTokenDto = jwtService.reissueAccessTokenByRefreshToken(refreshToken);
return ResponseEntity.ok(accessTokenDto);
}
}
refreshToken
을 Http body에 넣을지 header에 넣을지 고민을 했는데 accessToken
과 같이 Authorization
헤더에 넣기로 결정했다.JwtUtil
에서 메서드를 만들어 사용하기보다 JwtService
를 생성하였다.AccessTokenDto
는 아래와 같다.@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class AccessTokenDto {
private String grantType;
private String accessToken;
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
private Date accessTokenExpireTime;
}
@Service
@RequiredArgsConstructor
public class JwtService {
private final MemberService memberService;
private final JwtUtil jwtUtil;
public AccessTokenDto reissueAccessTokenByRefreshToken(String refreshToken) {
Long memberId = jwtUtil.getMemberId(refreshToken);
Member member = memberService.findByMemberId(memberId);
MemberInfoDto memberInfoDto = member.toMemberInfoDto();
Date accessTokenExpireTime = jwtUtil.createAccessTokenExpireTime();
String accessToken = jwtUtil.createAccessToken(memberInfoDto, accessTokenExpireTime);
return AccessTokenDto.builder()
.grantType("Bearer")
.accessToken(accessToken)
.accessTokenExpireTime(accessTokenExpireTime)
.build();
}
}
MemerService
와 JwtUtil
을 주입하였다. MemberService
를 거치지 않고 MemberRepository
를 주입하여 사용하고 싶었으나 repository는 해당 service에서 호출하려고 하였다.refreshToken
으로 MemberInfoDto
를 가져와 JwtUtil
의 createAccessToken
을 호출하여 새로운 accessToken
을 생성하였다.package spring.auth.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import spring.auth.auth.handler.CustomAccessDeniedHandler;
import spring.auth.auth.handler.CustomAuthenticationEntryPoint;
import spring.auth.auth.service.CustomUserDetailsService;
import spring.auth.enums.Role;
import spring.auth.jwt.JwtAuthenticationFilter;
import spring.auth.jwt.JwtUtil;
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService userDetailsService;
private final JwtUtil jwtUtil;
private final CustomAccessDeniedHandler accessDeniedHandler;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private static final String[] AUTH_WHITELIST = {
"/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", "/", "/login", "/signup",
"/api/v1/auth/**", "/swagger-ui/index.html#/", "/api/v1/jwt/reissue"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
//CSRF, CORS
http
.csrf(AbstractHttpConfigurer::disable)
.cors(Customizer.withDefaults())
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable);
//세션 관리 상태 없음으로 구성
http
.sessionManagement(sessionManagement -> sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.exceptionHandling((exceptionHandling) -> exceptionHandling
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
);
// JwtAuthFilter를 filterChain에 추가
http
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
// permit, authenticated 경로 설정
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(AUTH_WHITELIST).permitAll() // 지정한 경로는 인증 없이 접근 허용
.anyRequest().authenticated()); // 나머지 모든 경로는 인증 필요
return http.build();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(userDetailsService, jwtUtil);
}
}
Spring Security
에서 제공하는 Form Login을 disable하였다는 것이다. JWT인증을 사용하기에 직접 POST방식의 /login을 만들어야 했기 때문이다.accessToken
을 재발급받기 위한 /api/v1/jwt/reissue를 AUTH_WHITELIST
에 추가하였다.JwtAuthenticationFilter
를 filterChain에 추가하였다.http
.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
UsernamePasswordAuthenticationFilter
앞에 해당 필터를 추가함으로써 JWT인증을 수행하도록 설정하였다.JwtAuthenticationFilter
에서 userDetailsService
와 jwtUtil
을 주입해야하므로@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter() {
return new JwtAuthenticationFilter(userDetailsService, jwtUtil);
}
위와 같이 빈을 등록해야 한다.이와 같이 db에 잘 들어갔음을 확인할 수 있다.
login을 진행해보자
JwtInfoDto
가 성공적으로 body에 응답으로 왔다.accessToken
, refreshToken
의 유효기간을 계산할 수 있다.accessToken
의 유효시간은 7/15 20:08이다. 즉, 19:58 → 20:08 이므로 accessToken
의 유효기간을 10분으로 설정했음을 알 수 있다. (1000(ms) x 60(s) x 10(min) = 600000)refreshToken
의 유효시간은 8/14 19:58이다. 7/15 19:58 → 8/14 19:58 이므로 refreshToken
의 유효기간을 30일로 설정했음을 알 수 있다. (1000(ms) x 60(s) x 60(min) x 24(hour) x 30(days) = 2592000000)accessToken
을 Bearer Token에 넣어 보낸 요청이 인증에 성공하여 해당 페이지를 응답으로 받아온 것을 확인할 수 있다.refreshToken
으로 accessToken
을 재발급받아보자accessToken
으로 인증이 필요한 페이지에 접근하지 못하는걸 확인하였다.refreshToken
으로 새로 accessToken
을 발급받아 보았다.accessToken
으로 인증이 필요한 페이지에 접근 가능함을 확인할 수 있다.accessToken
과 refreshToken
을 프론트단에서 로컬 저장소에 저장을 한다. 요청마다 저장소에서 accessToken
을 가져와 헤더에 넣어 인증하도록 하며, accessToken
이 만료 시 refreshToken
을 가져와 refreshToken
을 발급받는 api로 요청을 보내 새로운 accessToken
을 받아올 수 있다고 생각하였다.Member
엔티티에 refreshToken
을 담는 생각도 하였지만 결국에 해당 Member를 찾으려면 인증이 필요하므로 로컬에 저장해야 맞는 로직이 될거라고 생각한다.추가로 accessToken
을 가지고 jwt 토큰을 인코딩, 디코딩해주는 jwt.io에서 디코딩을 해보았다.
다음과 같이 Encoded부분에 accessToken
을 입력하고 verify signature 부분에 secret string을 작성하면 해당 Signature가 Verified되었다는 메세지를 받을 수 있다.
Spring Security
를 이용하여 세션을 사용한 기본 Form Login부터 JWT인증 방식을 이용한 Form Login까지 구현해보았다.Spring Security
의 기본부터 차근차근 이해안되는 부분에서는 공식 문서를 읽으며 진행하였다. 처음엔 많이 버거웠지만 충분히 이해하며 구현할 수 있었다.