Spring Security
회원가입, 로그인 기능과 같이 인증/인가 작업을 spring security, JWT로 처리해보기로 했다.
이전부터 OAuth 방식으로 인증, 인가를 구현해보고 싶었는데 spring security를 사용하면 filter나 authenticationProvider, manager 같은 객체 생성, 연결이 어렵지 않을 것 같았다. 그래서 도입해보기로 함.
spring security 흐름도
의존성 추가
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
User
User Entity
@NoArgsConstructor
@Getter
@Entity
@Table(name = "user")
@DynamicUpdate
/* 삭제된 데이터는 가져오지 않음 */
@Where(clause = "deleted_at is NULL")
/* 삭제요청이 들어오면 실제로 데이터를 삭제하지 않고 삭제 시간을 기록한다. */
@SQLDelete(sql = "update user set deleted_at = CURRENT_TIMESTAMP where user_id = ?")
public class User extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id", nullable = false)
private Long userId;
// 사용자 계정 ID
@Column(name = "username", nullable = false)
private String username;
// 사용자 비밀번호
@Column(name = "password", nullable = false)
private String password;
// 사용자 닉네임
@Column(name = "nickname", nullable = false)
private String nickname;
// 사용자의 권한
@Column(name = "roles", nullable = false, length = 30)
private String roles;
// roles(권한) 데이터가 여러개 있다면 ,로 이어져있어 분리 후 List 구현 객체로 반환
public List<String> getRoleList() {
if (this.roles != null && this.roles.length() > 0) {
return Arrays.asList(this.roles.split(","));
}
return new ArrayList<>();
}
@Builder
public User(String username, String password, String nickname, String roles) {
this.username = username;
this.password = password;
this.nickname = nickname;
this.roles = roles;
}
}
PrincipalDetails
spring security에서 사용자 정보를 담은 객체
@Data
public class PrincipalDetails implements UserDetails {
// 내가 정의한 User Entity
private User user;
public PrincipalDetails(User user) {
this.user = user;
}
// 해당 사용자의 모든 권한 가져오기
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
user.getRoleList().forEach(role -> {
authorities.add(() -> role);
});;
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
PrincipalDetailsService
사용자의 정보를 가져오기 위한 UserDetailsService
을 구현한 클래스로, loadUserByUsername
을 오버라이딩해서 spring security가 추후 PrincipalDetails
을 가져올 수 있도록 한다.
@RequiredArgsConstructor
@Slf4j
@Service // 필수로 @Service 어노테이션을 달아야한다. 아니면 Spring security가 찾지 못한다.
public class PrincipalDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("[PrincipalDetailsService] loadUserByUsername {}", username);
Optional<User> user_optional = userRepository.findByUsername(username);
// 유저를 찾지 못하면 에러를 던진다.
if (user_optional.isEmpty()) throw new CustomException(ErrorCode.USER_NOT_FOUND);
return new PrincipalDetails(user_optional.get());
}
}
Config
WebSecurityConfig
Spring security 설정 파일.. jwt filter apply 수정 필요
CSRF
(Cross Site Request Forgery Attack): 사이트 간 요청 위조, 라고 한다. 간단히 말하면 인증된 사용자가 특정 URI로 요청을 보내도록 만드는(유도하는) 공격
e.g.) 이메일이나 웹 사이트에 링크를 걸어두고, 이를 클릭하면 특정 사이트에 제품 구입, 계정 설정, 기록 삭제, 비밀번호 변경, 문자 전송 등과 같은 요청을 보내도록 하는 것exceptionHandling()
: 인증 인가 과정에서 에러가 날 떄 핸들링해주기 위한 것으로, 이어서authenticationEntryPoint
나accessDeniedHandler
를 설정해줄 수 있다.- 이 외에는 주석 참고
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class WebSecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf().disable()
.formLogin().disable()
.exceptionHandling()
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.apply(new JwtSecurityConfig(jwtTokenProvider))
.and()
.authorizeHttpRequests()
// /api/user/sign-up 이나 /api/auth/login 로 들어오는 요청은 인증 및 인가가 필요 없도록
.requestMatchers("/api/user/sign-up").permitAll()
.requestMatchers("/api/auth/login").permitAll()
// 그 외로 들어오는 요청은 모두 인증이 필요하다.
.anyRequest().authenticated();
return http.build();
}
}
Controller 및 Service
AuthController
로그인과 같은 인증 관련 요청을 핸들링할 controller
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/auth")
@Slf4j
public class AuthController {
// 인증 관련 서비스 로직을 다루는 authService
private final AuthService authService;
/* Value 값 넣을 때는 static, final 안돼 */
@Value("${jwt.AUTHORIZATION_HEADER}")
private String AUTHORIZATION_HEADER;
@Value("${jwt.REFRESH_HEADER}")
private String REFRESH_HEADER;
private static final String PREFIX = "Bearer ";
@PostMapping("/login")
public ResponseEntity<Boolean> login(HttpServletResponse response, @RequestBody LoginRequestDTO request) {
log.info("로그인 시작");
// authService.login()에서 토큰 발급 받기
TokenDTO tokenDTO = authService.login(request);
// Authorization: Bearer <access token> 형식으로 헤더에 토큰 넣어 응답하기
response.setHeader(AUTHORIZATION_HEADER, PREFIX + tokenDTO.getAccessToken());
log.info(AUTHORIZATION_HEADER + ": " + PREFIX + tokenDTO.getAccessToken());
// Refresh: Bearer <refresh token> 형식으로 헤더에 토큰 넣어 응답하기
response.setHeader(REFRESH_HEADER, PREFIX + tokenDTO.getAccessToken());
log.info(String.valueOf(response));
// 정상적으로 토큰 발급되었다는 의미에서 true 반환
return ResponseEntity.status(HttpStatus.OK).body(true);
}
}
AuthService
인증, 인가 관련 서비스 로직을 담은 service
- 여기서 고치고 싶은건 AuthenticationManager를 여기서 buidler 로 생성하지 않고 securityConfig에서 생성한 예제를 본 것 같다. 그리고 UserDetailsService를 주입해줬던 것 같은데 .. 다시 보고 수정해야겠다.
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthServiceImpl implements AuthService {
// 인증 절차 관리하는 AuthenticationManager를 생성하는 AuthenticationManagerBuilder 객체 생성.
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final JwtTokenProvider jwtTokenProvider;
@Override
public TokenDTO login(LoginRequestDTO request) {
// 사용자의 username, password를 기반으로 Authentication의 구현체인 UsernamePasswordAuthenticationToken 객체를 만든다.
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
log.info("auth token: " + authenticationToken.getName() + " " + authenticationToken.getCredentials());
Authentication authentication =
authenticationManagerBuilder.getObject() // authenticationManager를 생성해
.authenticate(authenticationToken); // authenticationToken에 대한 인증 작업을 수행한다.
// 토큰 생성
TokenDTO tokenDTO = jwtTokenProvider.generateToken(authentication);
log.info("token DTO: {}", tokenDTO.toString());
/*
key-value 형식의 DB에 refresh token 저장해두는 코드 필요
*/
return tokenDTO;
}
}
JWT
JwtTokenProvider
JWT 토큰 발급 및 검증하는 객체
@Component
@Slf4j
public class JwtTokenProvider implements InitializingBean {
@Value("${jwt.SECRET}")
private String secret;
private static final String AUTHORITIES_KEY = "auth";
private static final String PREFIX = "Bearer";
private static long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24; // 1일
private static long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 2; // 2일
private Key key;
@Override
public void afterPropertiesSet() throws Exception {
// JwtTokenProvider 빈이 생성될 떄 key값 인코딩해서 클래스 객체 key에 넣어주기
byte[] encodedKey = Base64.getEncoder().encode(secret.getBytes());
this.key = Keys.hmacShaKeyFor(encodedKey);
}
/* 토큰 생성 메서드 */
public TokenDTO generateToken(Authentication authentication) {
// 모든 권한 가져와 ","로 이어 문자열로 변환
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
log.info("authentication: " + authentication.getName());
long now = new Date().getTime();
// access token 생성
String accessToken = Jwts.builder()
// 인증에서 가져온 username을 claim sub에 넣어주고
.setSubject(authentication.getName())
// auth:권한 형식으로 claim에 추가
.claim(AUTHORITIES_KEY, authorities)
// 만료시각은 현재(now)로부터 1일이 지난 때
.setExpiration(new Date(now + ACCESS_TOKEN_EXPIRE_TIME))
// key라는 비밀키와 HS256 알고리즘을 사용해 서명
.signWith(key, SignatureAlgorithm.HS256)
.compact();
String refreshToken = Jwts.builder()
// 만료시각은 현재(now)로부터 2일이 지난 때
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
// key라는 비밀키와 HS256 알고리즘을 사용해 서명
.signWith(key, SignatureAlgorithm.HS256)
.compact();
log.info("accessToken: " + accessToken.toString());
log.info("refreshToken: " + refreshToken.toString());
// accessToken과 refreshToken을 DTO 객체에 담아 반환
return TokenDTO.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
public Authentication getAuthentication(String accessToken) {
// accessToken에서 claims 빼내기
Claims claims = parseClaims(accessToken);
// 만약 claims에 권한이 null이라면 예외 던지기
if (claims.get(AUTHORITIES_KEY) == null) throw new CustomException(ErrorCode.INVALID_AUTH_TOKEN);
// claims에서 sub(이전에 넣었던 username)과 권한 목록 가져와셔 User 객체 생성
PrincipalDetails principal = new PrincipalDetails(
User.builder()
.username(claims.getSubject())
.roles(claims.get(AUTHORITIES_KEY).toString())
.build()
);
// Authentication 구현체인 UsernamePasswordAuthenticationToken 객체 생성
return new UsernamePasswordAuthenticationToken(principal, "", principal.getAuthorities());
}
/* 유효한 토큰인지 확인 */
public boolean validateToken(String token) {
try {
// key와 주어진 토큰을 검사한다.
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
log.info("유효한 토큰입니다.");
// 유효한 토큰이라면 true 반환
return true;
} catch (SecurityException | MalformedJwtException e) {
log.info("유효하지 않은 토큰입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("잘못된 토큰입니다.");
}
return false;
}
// accessToken에서 claims 뽑아내기
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
JwtAuthenticationFilter
JWT 를 검증하고 인증 정보 설정하는 역할의 필터
한번의 요청에 대해 한번만 실행되어야 하므로 OncePerRequestFilter
를 상속받아 구현
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 토큰 중 우리가 만들었던 JWT 부분만 가져온다.
String token = resolveToken(request);
// token 내용이 있으며 유효한 토큰이라면
if (token != null && jwtTokenProvider.validateToken(token)) {
// token을 기반으로 사용자 정보 객체(UsernamePasswordAuthenticationToken) 생성
Authentication authentication = jwtTokenProvider.getAuthentication(token);
// 그리고 인증된 정보는 context에 저장
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("set Authentication to security context for '{}', uri: {}", authentication.getName(), request.getRequestURI());
}
// 다음 단계 진행
filterChain.doFilter(request, response);
}
/* 토큰 유효한지 확인 + 문자열 처리 */
private String resolveToken(HttpServletRequest request) {
// accessToken 가져와서
String bearerToken = request.getHeader("Authorization");
// bearerToken이 공백을 제외하고 하나 이상의 문자를 갖고있으며 Bearer 로 시작한다면
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
/* 앞 "Bearer " 뗸 토큰 내용을 반환 */
return bearerToken.substring(7);
}
/* 예상과 다른 토큰이면 null 반환 */
return null;
}
@Override
public void destroy() {
super.destroy();
}
}
JWT Error handler
JwtAccessDeniedHandler
필요한 권한이 존재하지 않는 경우에 403 FORBIDDEN 에러 리턴
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
JwtAuthenticationEntryPoint
유요한 자격 증명을 제공하지 않고 접근하려할 때 401 Unauthorized 에러 리턴
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
'Web > Spring' 카테고리의 다른 글
[ERROR] gradle - java version (0) | 2024.06.09 |
---|---|
[JPA] M:N 관계 (@ManyToMany) (2) | 2024.01.30 |
[Exception Handler] Java, SpringBoot 에서의 예외 처리 (0) | 2023.12.23 |