Spring security는 스프링 기반의 애플리케이션의 인증과 인가 등의 보안을 담당하는 스프링 하위 프레임워크이다.
Spring security를 사용하면 Filter(필터)를 통해, 매 요청마다 세션을 검사하고, 매 요청마다 유저의 권한을 검사하지 않아도 된다.
Spring security 사용 시 스프링은 DispatcherServlet 앞단에 Filter를 배치시켜 요청을 가로채 확인하여, 클라이언트에 접근 권한이 없다면 인증화면으로 리다이렉트 시킨다.
클라이언트가 리소스를 요청할 때 접근 권한이 없는 경우 기본적으로 로그인 폼으로 보내개 되는데 그 역할을 하는 Filter가 UsernamePasswordAuthenticationFilter이다.
RestAPI 구조에서는 로그인 폼이 따로 존재하지 않으므로 인증 권한이 없다는 오류를 Json 형태로 반환해 주어야 한다.
토큰은 일반적으로 편의성과 보안성을 위해 Access Token과 Refresh Token 두개를 가진다. Access Token의 만료 시간을 짧게, Refresh Token의 만료 시간을 길게 잡으면 사용자의 편의와 보안을 높일 수 있다.
1. Access Token을 전송했을 때 만료시간으로 인해 실패한다면 Refresh Token을 확인한다.
2. Refresh Token이 일치하고, 만료되지 않았다면 Access Token을 새로 발급해서 새로운 만료시간을 갖게 해준다.
Access Token이 탈취당해도 만료시간이 짧으므로 보안성이 좋다. 하지만 Refresh Token이 탈취당하면 더 위험해 질 수 있다.
// Jwt
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
JWT 생성 및 유효성을 검증하는 컴포넌트이다. Jwts는 여러가지 암호화 알고리즘을 제공하고, 알고리즘과 비밀키를 통해 토큰을 생성하게 된다.
이때, Claim 정보에 토큰에 부가적으로 실어 보낼 정보를 담고, Jwt에는 expire 시간을 정해 해당 토큰의 만료시간을 정해줄 수 있다.
resolveToken 매서드는 Http Request header에서 세팅된 토큰값을 가져와서 유효성을 검사한다. 제한된 리소스에 접근할 때 Http Header에 토큰을 세팅하여 호출하면 유효성 검사를 통해 사용자 인증을 받을 수 있다.
JwtProvider
@RequiredArgsConstructor @Component public class JwtProvider {
@Value("${spring.jwt.secret}")
private String secretKey;
private long tokenValidMillisecond = 60 * 60 * 1000L; // 1 hour
private final CustomUserDetailService userDetailService;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// Jwt 생성
public String createToken(String userPk, List<String> roles) {
// user 구분을 위해 Claims 에 User Pk 값을 넣어줌
Claims claims = Jwts.claims().setSubject(userPk).build();
// 생성 날짜, 만료 날짜를 위한 Date
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidMillisecond))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
// Jwt 로 인증 정보 조회
public Authentication getAuthentication (String token) {
UserDetails userDetails = userDetailService.loadUserByUsername(this.getUserPk(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// jwt 에서 회원 구분 pk 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
}
// HTTP Request 의 Header 에서 Token Parsing -> "X-AUTH-TOKEN: jwt"
public String resolveToken(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
// jwt 의 유효성 및 만료일자 확인
public boolean validationToken(String token) {
try {
Jws<Claims> claimsJws = Jwts.parser().setSigningKey(secretKey).build().parseClaimsJws(token);
return !claimsJws.getBody().getExpiration().before(new Date()); // 만료 날짜가 현재에 비해 전이면 false
} catch (Exception e) {
return false;
}
}
}
> application-jwt.yml
spring:
jwt:
secret: {AccessToken}
token:
application.yml에서 application-jwt를 사용하게
spring:
profiles:
include: jwt
## 3. JwtAuthenticationFilter 생성
Jwt가 유효한 토큰인지 인증하기 위한 Filter이다. 이 필서를 Security 설정 시 UsernamePasswordAuthentication 앞에 세팅하여 로그인 폼으로 반환하기 전에 인증 여부를 Json으로 반환시킨다.
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtProvider jwtProvider;
// request 로 들어 오는 Jwt 의 유효성을 검증 - JwtProvider.validationToken() 을 필터로 FilterChain 에 추가
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain filterChain) throws IOException, ServletException {
String token = jwtProvider.resolveToken((HttpServletRequest) request);
// 검증
log.info("[Verifying token]");
log.info(((HttpServletRequest) request).getRequestURL().toString());
if (token != null && jwtProvider.validationToken(token)) {
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
}
## 4. SpringSecurity Configuration 작성
서버에 보안을 적용하기 위해
* 아무나 접근이 가능한 리소스: permitAll()
* 그 외 나머지: ROLE_USRE 권한이 필요함을 명시
@RequiredArgsConstructor
@Configuration
public class SecurityConfiguration {
private final JwtProvider jwtProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorizeRequests ->
authorizeRequests
.requestMatchers(HttpMethod.POST, "/accounts/signup", "/accounts/login").permitAll()
.requestMatchers(HttpMethod.GET, "/oauth/kakao/**").permitAll()
.requestMatchers(HttpMethod.GET, "/exception/**").permitAll()
.anyRequest().hasRole("USER")
)
.sessionManagement(sessionManagement ->
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.httpBasic(httpBasic -> httpBasic.disable())
.csrf(csrf -> csrf.disable())
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
## 5. UserDetailsService를 implements받아서 재정의
토큰에 세팅된 유저 정보로 회원정보를 조회하는 UserDetailService를 재정의한다.
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final UserJpaRepository userJpaRepository;
@Override
public UserDetails loadUserByUsername(String userPk) throws UsernameNotFoundException {
return userJpaRepository.findById(Long.parseLong(userPk))
.orElseThrow(() -> new AccountsExceptionHandler(ErrorCode.USER_NOT_FOUND));
}
}
UserJpaRepository에서 반환하는 엔티티는 User인데 User가 UserDetails를 상속하도록 하자.
스프링 시큐리티에서는 토큰에 포함된 유저 정보로 유저를 조회하는 것을 UserDetailsService 인터페이스에 만들어놨는데, 여기서는 단 하나의 매소드(loadUserByUsernam)가 존재한다. 이 메소드에서 UserDtails를 반환하도록 정의되어있다.
## 6. User 엔티티가 UserDeatils를 implements하도록
roles는 회원이 가지고 있는 권한 정보이고 가입 시 기본적으로 "ROLE_USER"가 세팅된다. 권한은 회원당 여러개가 정의 될 수 있으므로 컬렉션 타입으로 정의한다.
@Getter
@Builder
@DynamicInsert
@DynamicUpdate
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EntityListeners(AuditingEntityListener.class)
@Table(name = "accounts_user")
@Entity
public class User extends BaseEntity implements UserDetails {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 20)
private String name; // 사용자 이름
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Column(name = "password", nullable = false)
private String password;
@Column(name = "email", nullable = false)
private String email;
@Column(name = "last_login")
private LocalDateTime lastLogin;
@OneToOne(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private Profile profile;
// 회원이 가지고 있는 권한 정보, 기본: "ROLE_USER"
@ElementCollection
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return roles
.stream().map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public String getUsername() {
return null;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonExpired() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isAccountNonLocked() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
@Override
public boolean isEnabled() {
return true;
}
}
## 7. 가입 & 로그인 구현
가입 시에는 Password를 인코딩을 위하여 passwordEncoder를 설정해 준다.(PasswordEncoder를 @Bean으로추가) 로그인 성공 시 결과로 Jwt를 발급해 준다.
> AccountsController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/accounts")
@RestController
public class AccountsController {
private final AccountsService accountsService;
@PostMapping("/login")
public ApiResponse<UserLoginResponseDto> login(@Valid @RequestBody UserLoginRequestDto requestDto) {
return ApiResponse.onSuccess(accountsService.login(requestDto));
}
@PostMapping("/signup")
public ApiResponse<UserSignupResponseDto> signup(@Valid @RequestBody UserSignupRequestDto requestDto) {
return ApiResponse.onSuccess(accountsService.signup(requestDto));
}
}
> AccountsService
@RequiredArgsConstructor
@Transactional
@Service
public class AccountsService {
private final UserJpaRepository userJpaRepository;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
public UserLoginResponseDto login(UserLoginRequestDto requestDto) {
// 회원 정보 존재 하는지 확인
User user = userJpaRepository.findByEmail(requestDto.email())
.orElseThrow(() -> new AccountsExceptionHandler(ErrorCode.USER_NOT_FOUND));
// 회원 pw 일치 여부
if (!passwordEncoder.matches(requestDto.password(), user.getPassword())) {
throw new AccountsExceptionHandler(ErrorCode.PASSWORD_NOT_EQUAL);
}
// 로그인 성공 시 토큰 생성
String token = jwtProvider.createToken(user.getId().toString(), user.getRoles());
return UserLoginResponseDto.from(user, token);
}
public UserSignupResponseDto signup(UserSignupRequestDto requestDto) {
// pw, pw 확인 일치 확인
if (!requestDto.password().equals(requestDto.passwordCheck()))
throw new AccountsExceptionHandler(ErrorCode.PASSWORD_NOT_EQUAL);
// 이메일 중복 확인
if (userJpaRepository.existsByEmail(requestDto.email())) {
throw new AccountsExceptionHandler(ErrorCode.USER_ALREADY_EXIST);
}
String encodedPw = passwordEncoder.encode(requestDto.password());
User user = requestDto.toEntity(encodedPw);
return UserSignupResponseDto.from(userJpaRepository.save(user));
}
}
## 8. Postman을 통한 테스트


회원을 가입하고, 로그인하고 로그인 시 토큰이 발급되는것을 확일할 수 있다.