JWT에 대해서 알아보았습니다. 이제 이 JWT를 생성하고 관리하기 위해 spring security 를 적용한 후기를 기록했습니다.
JWT 관련된 포스트 : https://velog.io/@hey-hey/JWT-3ncumsy3
Spring Security는 Java 기반의 웹 애플리케이션과 서비스의 인증(Authentication)과 권한 부여(Authorization)를 처리하는 강력하고 유연한 보안 프레임워크입니다.
여러 장점 중 제가 선택한 이유는 스프링에서 사용하기가 용이하기 때문이었습니다.
스프링 컨테이너에서 의존성 주입, AOP 등의 기능과 결합이 쉬웠던 것이 결정의 큰 이유가 되었습니다.
바로 저희 프로젝트에서 적용해 보겠습니다.
spring security 와 jwt와 관련된 의존성 주입을 해줍니다.
build.gradle
// Security
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
// 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'
// Validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
그다음 필요한 파일들을 만드는데, 저는 전부 한 폴더 안에서 만들어 줬습니다.
security 폴더 안의 파일을 하나씩 만들어 보겠습니다.
그전에 엔티티를 먼저 수정해줍니다.
user 에 roles 과 관련된 부분을 추가해줍니다. 이 부분이 권한과 관련된 역할을 합니다.
roles는 Authority 객체의 array 형태입니다.
public class User{
...
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.EAGER)
@Builder.Default // builder를 사용할 때, 기본값으로 설정
private List<Authority> roles = new ArrayList<>();
public void setRoles(List<Authority> roles) {
this.roles = roles;
roles.forEach(o -> o.setUser(this));
}
}
권한과 관련된 엔티티입니다. Id와 name 이 존재합니다.
@Entity
@Getter @Setter
@AllArgsConstructor @NoArgsConstructor @Builder
public class Authority {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonIgnore
private Long authorityId;
private String authorityName;
@JoinColumn(name = "userPkId")
@ManyToOne(fetch = FetchType.LAZY)
@JsonIgnore
private User user;
}
Spring Security 는 유저 인증과정에서 UserDetails을 참조해서 인증합니다. UserDetails을 상속해 구현합니다.
UserDetails는 인증과 관련된 사용자의 정보를 담고 있는 인터페이스입니다. UserDetails는 Spring Security가 인증 과정에서 사용자 정보를 가져오고 처리하기 위해 필요한 메서드들을 정의하고 있습니다. 이 인터페이스는 다음 역할을 수행합니다.
바로 UserDetails를 사용해서 동작하게 되지만, User 엔티티를 사용하기 편리하게 하기 위해서 직접 구현하였습니다.
JWT를 이용할 것이기 떄문에 아래 4개의 속성은 return true 로 설정합니다.
public class CustomUserDetails implements UserDetails {
private final User user;
public CustomUserDetails(User user) {
this.user = user;
}
public User getUser() {
return user;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return user.getRoles().stream().map(o -> new SimpleGrantedAuthority(
o.getAuthorityName()
)).collect(Collectors.toList());
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
UserDetailsService는 Spring Security에서 사용자 정보를 조회하는 역할을 담당하는 인터페이스입니다. 찾은 사용자 정보를 UserDetails 객체로 변환하여 반환합니다.
스프링 시큐리티는 인증 과정에서 UserDetailsService를 호출하여 사용자 정보를 가져오고 인증을 수행할 수 있습니다.
이 인터페이스는 다음 역할을 수행합니다.
@Service
@RequiredArgsConstructor
public class JpaUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public CustomUserDetails loadUserByUsername(String email) throws UsernameNotFoundException{
User user = userRepository.findByEmail(email).orElseThrow(
() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다.")
);
return new CustomUserDetails(user);
}
}
JWT 를 생성하고 검증하는 클래스를 생성해줍니다.
@RequiredArgsConstructor
@Component
public class JwtProvider {
@Value("${jwt.secret.key}")
private String salt;
private Key secretKey;
// private final long exp = 1000L * 60 * 5 ;// 5분
private final long exp = 1000L * 60 * 60 * 24;// 1일
private final JpaUserDetailsService jpaUserDetailsService;
@PostConstruct
protected void init() {
secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
}
public String createToken(User user, List<Authority> roles) {
Claims claims = Jwts.claims().setSubject(user.getEmail());
claims.put("user",new UserDto(user)); // user 정보를 담는다.
claims.put("id",user.getId());
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + exp))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
// 권한정보 획득
// Spring Security 인증과정에서 권한확인을 위한 기능
public Authentication getAuthentication(String token) {
UserDetails userDetails = jpaUserDetailsService.loadUserByUsername(this.getUserEmail(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
public String getUserEmail(String token) {
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
}
private Integer getUserId(String token) {
return (Integer) Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().get("id");
}
// Authorization Header를 통해 인증을 한다.
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
public Long getUserInfo(HttpServletRequest request) {
String token = resolveToken(request).split(" ")[1].trim();
// Integer로 넘어오는데 Long으로 넘겨줘야함
return getUserId(token).longValue();
}
public boolean validateToken(String token) {
try {
// Bearer 검증
if (!token.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")) {
return false;
} else {
token = token.split(" ")[1].trim();
}
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
// 만료되었을 시 false
return !claims.getBody().getExpiration().before(new Date());
} catch (Exception e) {
return false;
}
}
}
exp
토큰의 유효시간을 설정할 수 있습니다.
@Value("${jwt.secret.key}")
암호 키를 사용하기 위해 application.yml에 등록을 해준 것을 가져옵니다.
application.yml
jwt:
secret:
key: F)J@NcRfUjXn2r5sadf2323@#2fda23
createToken
filter를 적용함으로써 servlet에 도달하기 전에 검증을 완료합니다.
// Jwt가 유효한 토큰인지 인증하기 위한 Filter
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
public JwtAuthenticationFilter(JwtProvider jwtTokenProvider) {
this.jwtProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtProvider.resolveToken(request);
if (token != null && jwtProvider.validateToken(token)) {
// check access token
token = token.split(" ")[1].trim();
Authentication auth = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
설정 파일입니다.
@Configuration // @Configuration을 붙여줘야 Bean으로 등록이 된다.
@RequiredArgsConstructor // final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다.
@EnableWebSecurity // Spring Security 설정할 클래스라고 정의한다.
public class SecurityConfig {
private final JwtProvider jwtProvider;
private static final String[] AUTH_LIST = {
"/api/auth/**",
"/swagger-resources/**",
"/swagger-ui/**",
"/swagger-ui.html",
"/v3/api-docs/**",
"/webjars/**",
"/files/**"
};
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// ID, Password 문자열을 Base64로 인코딩하여 전달하는 구조
.httpBasic().disable()
// 쿠키 기반이 아닌 JWT 기반이므로 사용하지 않음
.csrf().disable()
// CORS 설정
.cors(c -> {
CorsConfigurationSource source = request -> {
// Cors 허용 패턴
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(
List.of("*")
);
config.setAllowedMethods(
List.of("*")
);
return config;
};
c.configurationSource(source);
}
)
// Spring Security 세션 정책 : 세션을 생성 및 사용하지 않음
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 조건별로 요청 허용/제한 설정
.authorizeRequests()
//AUTH_LIST는 모두 승인 ⭐️
.antMatchers(AUTH_LIST).permitAll()
// /admin으로 시작하는 요청은 ADMIN 권한이 있는 유저에게만 허용
.antMatchers("/api/admin/**").hasRole("ADMIN")
// /user 로 시작하는 요청은 USER 권한이 있는 유저에게만 허용
.antMatchers("/api/**").hasRole("USER")
.anyRequest().denyAll()
.and()
// JWT 인증 필터 적용
.addFilterBefore(new JwtAuthenticationFilter(jwtProvider), UsernamePasswordAuthenticationFilter.class)
// 에러 핸들링
.exceptionHandling()
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 권한 문제가 발생했을 때 이 부분을 호출한다.
response.setStatus(403);
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("권한이 없는 사용자입니다.");
}
})
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 인증문제가 발생했을 때 이 부분을 호출한다.
response.setStatus(401);
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("인증되지 않은 사용자입니다.");
}
})
;
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
AUTH_LIST
인증이 없이도 허가되는 페이지 리스트를 정의합니다.
로그인/회원가입에는 인증이 없어야 하기 때문에 인증 api 부분과,swagger 관련된 페이지, 이미지를 부를 때 필요한 페이지를 추가로 넣어줬습니다.
.cors()
cors 설정 부분입니다. 저는 모든 요청을 허가해줬습니다.
.addFilterBefore()
인증을 처리하는 필터로 아까 만든 JwtAuthenticationFilter()를 사용했습니다.
컨트롤러를 등록해줍니다.
@RestController
@RequiredArgsConstructor
@Slf4j
@Api(value = "유저 인증 API", tags = {"auth"})
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final AuthService authService;
@PostMapping(value="/register")
@ApiOperation(value = "회원가입")
public ResponseEntity<Long> register( CreateUserForm request) throws Exception {
return new ResponseEntity<>(authService.register(request), HttpStatus.OK);
}
@PostMapping(value = "/login")
@ApiOperation(value = "로그인")
public ResponseEntity<AuthResponse> login(@RequestBody LoginForm request) throws Exception {
return new ResponseEntity<>(authService.login(request), HttpStatus.OK);
}
}
@Service
@Transactional // JPA의 모든 변경은 트랜잭션 안에서 이루어져야 한다.
@RequiredArgsConstructor // final이 선언된 모든 필드를 인자값으로 하는 생성자를 대신 생성해준다.
public class AuthService {
private final UserRepository userRepository;
private final UserService userService;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
public Long register(CreateUserForm createUserForm) throws Exception{
try{
User user = new User();
user.setEmail(createUserForm.getEmail());
user.setPassword(passwordEncoder.encode(createUserForm.getPassword()));
user.setNickName(createUserForm.getNickname());
user.setPhoneNumber(createUserForm.getPhoneNumber());
MultipartFile file = createUserForm.getProfileImage();
if (file != null) {
Image image = new Image(file);
user.setProfileImage(image);
}
user.setRoles(Collections.singletonList(Authority.builder().authorityName("ROLE_USER").build()));
return userService.saveUser(user);
}catch (Exception e){
throw new Exception(e);
}
}
public AuthResponse login(LoginForm request) throws Exception {
User user = userRepository.findByEmail(request.getEmail()).orElseThrow(
() -> new BadCredentialsException("사용자를 찾을 수 없습니다.")
);
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
}
return AuthResponse.builder()
.id(user.getId())
.email(user.getEmail())
.nickName(user.getNickName())
.phoneNumber(user.getPhoneNumber())
.token(jwtProvider.createToken(user, user.getRoles()))
.build();
}
public AuthResponse getUser(String userId) throws Exception {
User user = userRepository.findByEmail(userId)
.orElseThrow(() -> new Exception("계정을 찾을 수 없습니다."));
return new AuthResponse(user);
}
}
회원가입에서는 패스워드를 설정할 때, 인코더를 통해서 변경해줘야 합니다.
그리고 user에 roles를 설정해주는 것이 권한을 추가해주면서 회원가입의 주 로직입니다.
로그인에서는 유저를 먼저 찾고, 유저와 패스워드가 일치하는지 확인합니다.
passwordEncoder
의 matches()
를 통해서 확인할 수 있습니다.
AuthResponse은 로그인을 하면, 토큰을 주는 객체 입니다.
새로운 token을 (jwtProvider.createToken(user, user.getRoles()))을 이용해서 만들어줍니다.
AuthResponse
@Getter @Setter
@AllArgsConstructor // 모든 필드 값을 파라미터로 받는 생성자를 생성
@NoArgsConstructor // 파라미터가 없는 생성자를 생성
@Builder
public class AuthResponse {
private Long id;
private String email;
private String nickName;
private String phoneNumber;
private String token;
public AuthResponse(User user) {
this.id = user.getId();
this.email = user.getEmail();
this.nickName = user.getNickName();
this.phoneNumber = user.getPhoneNumber();
}
}
회원 정보를 항상 실어 보내지 않고, 토큰을 통해서 작업을 하기 위해서, 유저 Id를 받아서 사용해보겠습니다. 여기서는 user Update 부분을 통해 예시를 들어보겠습니다.
(서비스 로직은 생략하겠습니다. )
@PutMapping("")
public ResponseEntity<UserDto> updateUser(HttpServletRequest request, UpdateUserForm updateUserForm)throws Exception{
Long userId = jwtProvider.getUserInfo(request);
return new ResponseEntity<>(
userService.updateUser(userId, updateUserForm), HttpStatus.OK
);
}
HttpServletRequest request
에 토큰 정보가 실려 있습니다.
JwtProvider 에 getUserInfo() 를 통해서 userId를 가져옵니다.
token을 파싱해서, key 가 id 인 value 를 가져오는 로직을 만들어서 사용하였습니다.
public Long getUserInfo(HttpServletRequest request) {
String token = resolveToken(request).split(" ")[1].trim();
// Integer로 넘어오는데 Long으로 넘겨줘야함
return getUserId(token).longValue();
}
private Integer getUserId(String token) {
return (Integer) Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().get("id");
}
이제 토큰을 통해 그 유저가 자신의 정보가 맞다면, 수정 삭제가 가능하게끔 하는 로직도 가능해집니다.
시큐리티를 적용해보며 인증과 권한의 중요성에 대해서 경험할 수 있었습니다. 구현하기에는 어려웠지만, 확실히 인증이 추가되었기 때문에 이제 서비스다운 서비스가 되었다 라는 생각이 듭니다.