https://growth-coder.tistory.com/166
이전 포스팅에서 JWT를 생성하는 JwtUtil 클래스를 만들어보았다.
이제는 스프링 시큐리티를 사용하여 인증 과정을 시큐리티에게 맡기려고 한다.
구현에 앞서 스프링 시큐리티의 로그인 과정을 그림으로 살펴보려고 한다.
- http 요청이 오면 AuthenticationFilter에서 그 요청을 받는다.
- username과 password를 인자로 넘겨주어 UsernamePasswordAuthenticationToken을 생성한다.
- AuthenticationManager 객체는 authenticate 메소드를 가지고 있는데 이 메소드의 인자로 2번에서 생성한 token을 넣어준다.
- AuthenticationManager의 authenticate 메소드는 인증 과정을 처리해줄 수 있는 AuthenticationProvider를 찾아서 Authenticate 메소드의 인자로 token을 넣어준다. (실제 인증 과정은 AuthenticationProvide에서 진행됨)
- UserDetailsService의 loadUserByUsername 함수를 실행한다.
- loadUserByUsername 함수는 username을 인자로 받아서 이에 해당하는 UserDetails를 가져온다.
- 전달
- 전달
- 최종적으로 Authentication 객체를 AuthenticationFilter에 전달한다.
- 이 Authentication 객체를 SecurityContextHolder의 SecurityContext에 저장한다.
구현하는 과정을 따라가보면 이해할 수 있을 것이다.
우리가 직접 구현할 부분은 AuthenticationFilter와 UserDetailsService이고 User 대신 custom한 Member를 사용해 볼 예정이다.
JPA를 사용할 예정이고 주 목적은 인증 처리이기 때문에 H2 Database를 선택했다.
lombok과 spring security도 넣어준다.
이후 build.gradle에 다음 코드를 넣어준다. jwt를 사용하기 위한 dependency와 javax.xml.bind.DatatypeConverter를 찾지 못하는 오류를 방지하기 위한 dependency이다.
dependencies{
...
implementation 'io.jsonwebtoken:jjwt:0.9.1'
implementation 'javax.xml.bind:jaxb-api:2.3.0' //javax.xml.bind.DatatypeConverter 오류 방지
}
application.properties는 다음과 같다.
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.url=jdbc:h2:mem:test
spring.datasource.username=sa
spring.datasource.password=
jpa.database-platform=org.hibernate.dialect.H2Dialect
jwt.secretKey=secretKey
jwt.expiredMs=60000
일반 유저와 관리자를 나눠서 권한을 부여할 예정인데 이를 위해 enum 타입으로 Role을 관리한다.
public enum Role {
USER("ROLE_USER"),
ADMIN("ROLE_ADMIN");
private final String roleName;
Role(String roleName) {
this.roleName = roleName;
}
public String getRole() {
return roleName;
}
}
<Member> 엔티티
@Entity
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long Id;
private String username;
private String password;
private String role;
private List<Role> roles;
}
<MemberDto> 회원 가입 및 로그인 할 때 사용할 dto
@Getter
public class MemberDto {
private String username;
private String password;
}
<MemberRepository>
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {
Member findByUsername(String username);
}
우선 UserDetails를 구현한 PrincipalDetails를 만든다. 멤버 변수로는 Member 객체를 가지고 있어야 하고 생성자도 만들어준다.
위 사진의 6번 과정에서 UserDetails를 구현한 PrincipalDetails 객체를 가져오게 될 것이다.
이제 JwtUtil 클래스를 만들 차례이다.
이전 포스팅에서는 메소드를 전부 static 메소드로 정의했었는데 이번 포스팅에서는 스프링 빈으로 등록해서 싱글톤으로 관리하려고 한다.
또한 시크릿 키와 유효 기간을 application.properties에 따로 저장해서 @Value 어노테이션으로 가져오려고 한다.
<JwtUtil>
@Component
public class JwtUtil {
@Value("${jasypt.encryptor.secretKey}") //application.properties에 저장되어 있는 값을 가져온다.
private String secretKey;
@Value("${jwt.expiredMs}") //application.properties에 저장되어 있는 값을 가져온다.
private Long expiredMs;
public String getMemberName(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
.getBody().get("username", String.class);
}
public boolean isExpired(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token)
.getBody().getExpiration().before(new Date());
}
public String createJwt(String memberName, Long id) {
Claims claims = Jwts.claims();
claims.put("username", memberName);
claims.put("id", id);
System.out.println(secretKey);
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return token;
}
}
UserDetails를 구현한 PrincipalDetails이다.
계정 사용 가능에 대해선 모두 true를 반환하고 권한을 반환하는 getAuthorities 메소드에서는 Member의 모든 Role을 가져와서 GrantedAuthority의 Collection으로 만들어서 반환해준다.
<PrincipalDetails>
@Getter
public class PrincipalDetails implements UserDetails {
private Member member;
public PrincipalDetails(Member member) {
this.member=member;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() { //유저에게 부여된 권한들 반환
List<Role> roles = member.getRoles();
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
for (Role role : roles) {
authorities.add(new SimpleGrantedAuthority(role.getRole()));
}
return authorities;
}
@Override
public String getPassword() {
return member.getPassword();
}
@Override
public String getUsername() {
return member.getUsername();
}
@Override
public boolean isAccountNonExpired() { //계정 만료 여부
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
} //계정 잠김 여부
@Override
public boolean isCredentialsNonExpired() {
return true;
} //credentials(password) 만료 여부
@Override
public boolean isEnabled() {
return true;
} //유저 사용 가능 여부
}
UserDetailsService를 확장한 커스텀 PrincipalDetailsService를 생성한다.
<PrincipalDeatilsService>
@RequiredArgsConstructor
@Service
public class PrincipalDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByUsername(username);
System.out.println("member = " + member);
return new PrincipalDetails(member);
}
}
이제 AuthenticationFilter를 만들 차례이다.
UsernamePasswordAuthenticationFilter를 확장한 JwtAuthenticationFilter를 만들어준다.
attempAuthentication과 successfulAuthentication 메소드를 override 해준다.
두 메소드를 커스텀할 예정이다.
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
return super.attemptAuthentication(request, response);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
super.successfulAuthentication(request, response, chain, authResult);
}
}
attempAuthentication에서는 인증을 수행하고 successfulAuthentication에서는 인증을 성공적으로 마치면 응답 헤더에 토큰을 넣어줄 예정이다.
위 사진에서 실제 인증 과정(비밀번호 일치 여부 등등..)은 AuthenticationProvider에서 진행된다고 했다.
즉 attempAuthentication에서는 AuthenticationManager의 authenticate 메소드를 사용할 것이고 AuthenticationManager는 AuthenticationProvider의 authenticate 메소드를 사용할 것이다.
실제 인증 과정은 AuthenticationProvider의 authenticate 메소드에서 일어나는 것이고 앞서 언급했다싶이 AuthenticationManager와 AuthenticationProvider는 직접 구현하지는 않을 것이다.
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtUtil jwtUtil;
//로그인을 시도할 때 실행
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
ObjectMapper objectMapper = new ObjectMapper();
MemberDto memberDto = null;
try {
memberDto = objectMapper.readValue(request.getInputStream(), MemberDto.class); //request로 들어온 JSON 형식을 MemberDto로 가져옴
} catch (Exception e) {
e.printStackTrace();
}
//토큰 생성
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(memberDto.getUsername(), memberDto.getPassword());
//authenticationManager의 authenticate 메소드 실행.
//authenticationManager는 처리할 수 있는 authenticationProvider를 찾아서 authenticationProvider의 authenticate 메소드 실행.
Authentication authenticate = authenticationManager.authenticate(token);
//Authentication 객체 반환. 세션에 저장됨.
return authenticate;
}
//인증을 성공하면 실행
//response Authorization header에 jwt를 담아서 보내줌
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
PrincipalDetails principal = (PrincipalDetails) authResult.getPrincipal();
Member member = principal.getMember();
String jwt = jwtUtil.createJwt(member.getUsername(), member.getId());
response.setHeader("Authorization", jwt);
}
}
이제 이 필터를 등록할 차례이다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
return httpSecurity
.csrf().disable() // rest api 방식으로 구현을 했기 때문에 csrf가 필요하지 않음
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.formLogin().disable() //jwt라 formLogin 필요 없음.
.httpBasic().disable() //스프링에서 기본적으로 제공하는 로그인 페이지 사용 안 함.
.apply(new MyCustomDsl())
.and().authorizeRequests()
.requestMatchers("/user/**")
.access("hasRole('ROLE_USER') or hasRole('ROLE_ADMIN')")
.requestMatchers("/admin/**")
.access("hasRole('ROLE_ADMIN')")
.anyRequest().permitAll().and().build();
}
public class MyCustomDsl extends AbstractHttpConfigurer<MyCustomDsl, HttpSecurity> {
@Override
public void configure(HttpSecurity http) throws Exception {
AuthenticationManager authenticationManager = http.getSharedObject(AuthenticationManager.class);
http
.addFilter(new JwtAuthenticationFilter(authenticationManager, jwtUtil));
}
}
}
BcryptEncoder도 등록해준다.
@Configuration
public class BcryptConfig {
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
이제 로그인을 위한 준비가 끝났다.
컨트롤러를 만들어준다.
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberRepository memberRepository;
private final BCryptPasswordEncoder encoder;
@PostMapping("/join")
public String join(@RequestBody MemberDto memberDto) {
ArrayList<Role> roleList = new ArrayList<>();
roleList.add(Role.USER);
Member member = Member.builder()
.username(memberDto.getUsername())
.password(encoder.encode(memberDto.getPassword()))
.roles(roleList).build();
memberRepository.save(member);
return "회원가입 성공";
}
}
스프링 시큐리티에서 기본 로그인 경로가 "/login"으로 매핑되어있기 때문에 login에 대해서는 따로 매핑해주지 않아도 된다.
회원 가입을 통해 H2 데이터베이스에 유저 정보를 저장하고 "/login" 경로로 MemberDto 형식으로 요청을 보내본다.
응답 헤더의 Authorization에 jwt 값이 들어가있는 모습을 확인할 수 있다.
이번 포스팅에서 인증 과정을 진행해보았는데 다음 포스팅에서 인가 과정을 통해 권한을 부여하는 JwtAuthorizationFilter를 구현해볼 예정이다.
참고
https://velog.io/@sunil1369/Spring-Spring-Security-Jwt
'공부 > Spring' 카테고리의 다른 글
[Spring] JPA 엔티티 다대일(N:1) 연관 관계 매핑 (빌더 패턴 사용) (0) | 2023.05.15 |
---|---|
[Spring] 빌더 패턴에 대한 이해와 사용법 (2) | 2023.05.12 |
[Spring] 스프링 jwt 생성, 검증, 값 가져오기 구현 (0) | 2023.04.30 |
[Spring] 스프링 시큐리티 default 로그인 경로 변경하는 방법 ("/login") (0) | 2023.04.29 |
[Spring][WebSocket] 스프링 STOMP와 웹 소켓 개념 및 사용법 (Web Socket with STOMP) (1) (2) | 2023.04.20 |
댓글