본문 바로가기
공부/Spring

[Spring] 스프링 시큐리티와 jwt 사용해서 인증 및 인가 구현 (1)

by 웅대 2023. 5. 1.
728x90
반응형

https://growth-coder.tistory.com/166

 

이전 포스팅에서 JWT를 생성하는 JwtUtil 클래스를 만들어보았다.

 

이제는 스프링 시큐리티를 사용하여 인증 과정을 시큐리티에게 맡기려고 한다.

 

구현에 앞서 스프링 시큐리티의 로그인 과정을 그림으로 살펴보려고 한다.

www.springbootdev.com

  1. http 요청이 오면 AuthenticationFilter에서 그 요청을 받는다.
  2. username과 password를 인자로 넘겨주어 UsernamePasswordAuthenticationToken을 생성한다.
  3. AuthenticationManager 객체는 authenticate 메소드를 가지고 있는데 이 메소드의 인자로 2번에서 생성한 token을 넣어준다.
  4. AuthenticationManager의 authenticate 메소드는 인증 과정을 처리해줄 수 있는 AuthenticationProvider를 찾아서 Authenticate 메소드의 인자로 token을 넣어준다. (실제 인증 과정은 AuthenticationProvide에서 진행됨)
  5. UserDetailsService의 loadUserByUsername 함수를 실행한다.
  6. loadUserByUsername 함수는 username을 인자로 받아서 이에 해당하는 UserDetails를 가져온다.
  7. 전달
  8. 전달
  9. 최종적으로 Authentication 객체를 AuthenticationFilter에 전달한다.
  10. 이 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://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-%EC%8B%9C%ED%81%90%EB%A6%AC%ED%8B%B0/dashboard

 

[무료] 스프링부트 시큐리티 & JWT 강의 - 인프런 | 강의

스프링부트 시큐리티에 대한 개념이 잡힙니다., - 강의 소개 | 인프런

www.inflearn.com

https://velog.io/@sunil1369/Spring-Spring-Security-Jwt

 

728x90
반응형

댓글