Spring Boot 프로젝트를 진행하면서 로그인 기능을 구현하게 되었는데 레퍼런스 검색 시 제일 많이 나오는 것이 Spring Security + JWT였다. 로그인 기능은 어디서든 쓰일 수 있기 때문에 추후 다시 쓸 것을 고려해서 Spring Security와 JWT에 대해 정리하는 포스트를 작성하기로 하였다.
Spring Security는 Spring을 기반으로 하는 애플리케이션의 보안을 담당하는 Spring 하위 프레임워크이다.
Spring Security는 크게 인증 절차를 진행 한 후에 인가 절차를 진행하는 두 가지 동작을 수행한다. 인가 과정에서 해당 리소스에 대한 접근 권한이 있는지 확인을 한다.
Spring Security는 이러한 인증과 인가를 위해 Principal을 아이디로 Credential을 비밀번호로 사용하는 Credential 기반의 인증 방식을 사용한다.
1. Client로부터 Http Request를 수신한다.
2. AuthentiactionFilter가 요청을 가로채어 UsernamePasswordAuthenticationToken(인증용 객체)을 생성한다.
3. 생성한 인증용 객체를 AuthenticationFilter를 통해 AuthentiactionManager에게 전달한다.
4. 인증을 처리할 수 있는 AuthenticationProvider를 선택하여 인증용 객체를 다시 전달한다.
5. 실제 DB에서 사용자 정보를 불러올 UserDetailsService에게 사용자 이름(username)을 넘겨준다.
6. 넘겨받은 사용자 이름(username)을 통해 DB에서 사용자 정보를 찾고 이를 UserDetails 객체로 반환한다.
7. UserDetails객체를 AuthenticationProvider에게 전달
8. AuthenticationProvider는 전달받은 UserDetails을 사용자의 입력 정보와 비교하고 인증에 성공하면 AuthenticationManager에게 검증된 인증 객체를 전달한다. 이 때 인증을 실패하면 AuthenticationException을 던진다.
9. 검증된 인증 객체를 AuthenticationFilter에게 전달한다.
10. 검증된 인증 객체를 SecurityContextHolder에 담은 이후 AuthenticationSuccessHandler를 실행한다. 실패 시 AuthenticationFailureHandler를 실행한다.
JWT(JSON Web Token)란 클라이언트와 서버 사이에서 통신할 때 권한을 위해 사용되는 토큰이다. JWT는 토큰 자체를 정보로 사용하는 Self-Contained 방식으로 정보를 전달한다.
JWT는 헤더(Header), 페이로드(Payload), 서명(Signature)로 나누어져 있다.
해당 프로젝트는 Java 11, SpringBoot 2.7.15 gradle 환경에서 진행되었기 때문에 build.gradle에 종속성을 추가하였다.
// Spring Security 추가
implementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-security'
// JWT 추가
implementation 'io.jsonwebtoken:jjwt:0.9.1'
사용자는 studentId
, password
, name
, role
, email
로 구성된다. studentId
가 아이디 역할을 한다.
@Entity
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Integer studentId;
private String password;
private String name;
@Enumerated(EnumType.STRING)
private Role role;
private String email;
}
Role은 사용자의 권한을 위해 만들었고 enum 클래스로 만들었다. STUDENT, PROFESSOR, ADMIN으로 나뉘며 @JsonValue와 @JsonCreator에 관해서는 추후 포스팅 할 예정이다.
public enum Role {
ROLE_STUDENT("ROLE_STUDENT"),
ROLE_PROFESSOR("ROLE_PROFESSOR"),
ROLE_ADMIN("ROLE_ADMIN");
String role;
Role(String role) {
this.role = role;
}
@JsonValue
public String value() {
return role;
}
@JsonCreator
public static Role parsing(String inputValue) {
return Arrays.stream(Role.values()).filter(type -> type.value().equals(inputValue)).findFirst().orElse(null);
}
}
MemberRepository는 JpaRepository를 상속받아 작성한다. 이 때 <>
안은 Entity, Entity의 PK type이다.
MemberRepository는 DB에 접근하는 메서드를 사용하기 위한 인터페이스로 본인의 코드에 맞게 메서드를 선언해주면 된다. 필자는 학번 즉 studentId
로 DB에서 Member를 찾을 것이기에 findBystudentId
로 선언하였다.
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findBystudentId(Integer studentId);
}
UserDetails는 Spring Security가 관리하는 User의 세부정보를 나타내는 역할을 한다.
Spring Security의 AuthenticationProvider가 UserDetailsService를 통해 DB에 있는 User 정보를 불러오는데 이 때 UserDetailsService는 DB의 User 정보를 UserDetails라는 객체로 반환한다.
UserDetails는 interface
이므로 구현을 통해 class
로 만들어준다. 그래서 Custom이다. 위에서 작성한 Entity에 맞게 작성해준다.
public class MemberDetails implements UserDetails {
private final Member member;
public MemberDetails(Member member) {
this.member = member;
}
public final Member getMember() {
return member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(member.getRole().value()));
return authorities;
}
@Override
public String getUsername() {
return String.valueOf(member.getStudentId());
}
@Override
public String getPassword() {
return member.getPassword();
}
@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의 AuthenticationProvider가 DB에 있는 User 정보를 불러올 때 사용된다.
UserDetailsService 또한 interface
이므로 구현체를 작성해준다. loadUserByUsername
메서드를 반드시 Override 해주어야 하는데 인자 타입이 String
인 것에 유념해준다. 필자는 studentId
를 ID로 사용하였고 studentId
의 type이 Integer
이기 때문에 인자를 형변환하였다.
@Service
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findBystudentId(Integer.valueOf(username)).orElseThrow(
() -> new UsernameNotFoundException("Invalid Authentication.")
);
return new MemberDetails(member);
}
}
JwtProvider는 토큰을 생성하고 검증하기 위한 클래스이다.
Secret Key는 256bit(32bytes)이상이 되어야 한다.
리눅스 사용자라면 터미널에서openssl rand -hex 64
입력해보자. 64bytes의 랜덤한 문자열을 반환해준다. 이를 Secret Key로 사용하면 된다. 보안을 위해 secretKey 작성은 생략하였다.
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
private String secretKey = "";
// 토큰 유효시간 30분
private final long tokenValidTime = 1000L * 60 * 30;
private final MemberDetailsService userDetailsService;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// token 생성
public String createToken(String account, List<String> roles) {
Claims claims = Jwts.claims().setSubject(account);
claims.put("roles", roles);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
// 권한 정보 획득
// Spring Security 인증과정에서 권한확인을 위한 기능
public Authentication getAuthentication(String token) {
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getAccount(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// token에 담겨있는 멤버 account 획득
public String getAccount(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Authorization Header를 통한 인증
public String resolveToken(HttpServletRequest request) {
return request.getHeader("Authorization");
}
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.parser().setSigningKey(secretKey).parseClaimsJws(token);
return !claims.getBody().getExpiration().before(new Date()); // 만료되었다면 false return
} catch(Exception e) {
return false;
}
}
}
요청이 Servlet으로 도달하기 전에 가로채어 UsernamePasswordAuthenticationToken 객체를 만들고 이를 AuthenticationManager에게 전달한다.
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveToken(request);
if(token != null && jwtTokenProvider.validateToken(token)) {
token = token.split(" ")[1].trim();
Authentication auth = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
Spring Security의 설정을 위한 클래스이다.
antMatcher()
를 통해 권한에 따른 접속 가능 url을 설정할 수 있다.
permitAll()
은 모든 권한에 대한 허용
hasRole()
은 특정 권한에 대한 허용을 설정한다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
private final JwtTokenProvider jwtTokenProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.httpBasic().disable()
.csrf().disable()
// CORS 설정
.cors(c -> {
CorsConfigurationSource source = request -> {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("*"));
config.setAllowedMethods(List.of("*"));
return config;
};
c.configurationSource(source);
}
)
// Session 정책 설정
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 조건별 요청 허용/제한 설정
.authorizeRequests()
.antMatchers("/api/member/login", "/api/reservation/**", "/v2/api-docs", "/swagger-resources/**", "/swagger-ui/index.html", "/swagger-ui.html","/webjars/**", "/swagger/**", "/favicon.ico").permitAll()
.antMatchers("/api/member/admin/**").hasRole("ADMIN") // /admin으로 시작하는 요청은 ADMIN 권한이 있는 유저에게만 허용
.antMatchers("/api/member/**").hasAnyRole("STUDENT", "PROFESSOR", "ADMIN")
.anyRequest().denyAll()
.and()
// JWT 인증 필터
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
// error handling
.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("Unauthorized User.");
}
})
.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("Unauthenticated User.");
}
});
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
login request, response에 사용될 Dto를 만들어준다. 본인이 사용하고자 하는 방향대로 작성해주면 된다.
request에는 login할 때 필요한 id(studentId)
와 password
를 담게끔 하였다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequestDto {
private Integer studentId;
private String password;
}
response에서는 id와 token에 대한 정보만 알려주도록 하였다.
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponseDto {
private Long id;
private String token;
}
멤버의 이름 정보를 가져오기 위한 Dto이다.
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class GetUserDto {
private String userName;
}
관리자가 멤버의 모든 정보를 가져오기 위한 Dto이다.
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class GetUserAllInfoDto {
private Long id;
private Integer studentId;
private String name;
private Role role;
private String email;
}
실제 프로젝트에서는 아직 개발 중이라 ADMIN(관리자) 페이지의 API url을 임시로 /api/member로 하였다.
하지만 Security Config에서는 ADMIN만 허용하는 url을 /api/member/admin/** 으로 하였기 때문에 동작 테스트를 위해getUserAsAdmin()
이라는 임의의 메소드를 선언하였다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/member")
public class MemberController {
private final MemberService memberService;
// 로그인 요청
@PostMapping("/login")
private ResponseEntity<LoginResponseDto> loginRequest(@RequestBody LoginRequestDto request) throws Exception {
return ResponseEntity.ok().body(memberService.login(request));
}
// 로그인 정보 가져오기(해당 멤버의 이름 가져오기)
@GetMapping("/{id}") // id : member-id
private ResponseEntity<GetUserDto> getUserById(@PathVariable Long id) {
return ResponseEntity.ok().body(memberService.getUserNameById(id));
}
// 로그인 정보 가져오기(해당 멤버의 모든 정보 가져오기)
@GetMapping("/admin/{id}") // id : member-id
private ResponseEntity<GetUserAllInfoDto> getUserAsAdmin(@PathVariable Long id) {
return ResponseEntity.ok().body(memberService.getUserAsAdmin(id));
}
}
구현하고자 하는 로직에 맞춰서 메소드를 작성해준다.
@Service
@RequiredArgsConstructor
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
@Override
public LoginResponseDto login(LoginRequestDto request) throws Exception {
Member member = memberRepository.findBystudentId(request.getStudentId()).orElseThrow(() ->
new BadCredentialsException("Invalid Account Information."));
if(!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
throw new BadCredentialsException("Invalid Account Information.");
}
List<String> roles = new ArrayList<>();
roles.add(member.getRole().value());
return LoginResponseDto.builder()
.id(member.getId())
.token(jwtTokenProvider.createToken(String.valueOf(member.getStudentId()), roles))
.build();
}
@Override
public GetUserDto getUserNameById(Long id) {
Member member = memberRepository.findByid(id);
GetUserDto newDto = new GetUserDto();
newDto.setUserName(member.getName());
return newDto;
}
@Override
public GetUserAllInfoDto getUserAsAdmin(Long id) {
Member member = memberRepository.findByid(id);
GetUserAllInfoDto newDto = new GetUserAllInfoDto();
newDto.setId(member.getId());
newDto.setStudentId(member.getStudentId());
newDto.setName(member.getName());
newDto.setRole(member.getRole());
newDto.setEmail(member.getEmail());
return newDto;
}
}
사전에 DB에 데이터를 넣어주었다. 넣어준 데이터는 다음과 같다.
name | password | role | studend_id | |
---|---|---|---|---|
test1@gmail.com | test1 | test!! | ROLE_STUDENT | 20201111 |
test2@gmail.com | test2 | test@@ | ROLE_ADMIN | 20202222 |
로그인 전, 즉 인증이 되기 전 멤버의 이름을 가져와본다.
인증이 되지 않았고 토큰이 없기 때문에 설정한대로 "Unauthenticated User."이 출력된다.
DB에 세팅한대로 로그인을 하면 LoginResponseDto에 id와 token 정보가 담겨 반환된다.
로그인 후, 즉 인증이 완료된 후 발급된 토큰을 가지고 다시 멤버의 이름을 가져와본다.
인증이 완료되었고 토큰이 유효하기 때문에 정상적으로 멤버의 이름을 반환한다.
인증이 완료된 후 발급된 토큰이 아닌 잘못된(훼손된) 토큰을 가지고 멤버의 이름을 가져와본다.
토큰이 유효하기 않기 때문에 "Unauthenticated User."이 출력된다.
위에서 로그인 하고 발급 받은 토큰은 STUDENT 권한에 대한 토큰이므로 ADMIN 권한에 대한 URL에는 접근이 제한되는 것을 확인할 수 있다. 설정한대로 "Unauthorized User."이 출력된다.
ROLE_ADMIN 권한을 가진 멤버로 다시 로그인 하고 이 때 발급된 토큰으로 API 요청을 보내면 정상적으로 멤버 정보가 출력되는 것을 확인할 수 있다.
https://velog.io/@kyungwoon/Spring-Security-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC
https://velog.io/@hahan/JWT%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80
https://mangkyu.tistory.com/56
잘 보고 갑니다^^