Development/Spring

[Spring + Jwt] Spring Boot + Spring Security + Redis + Jwt를 이용한 회원가입 및 로그인

hyelie 2022. 10. 5. 10:33

https://bcp0109.tistory.com/301

 

이 글을 많이 참고했다.

 

spring security를 이용하는 이유는 spring에서 로그인 기능을 구현하기 위해서이고 session이 아니라 token을 사용하려 하는 이유는 앱 환경에서 로그인을 유지시켜주기 위함이다. jwt는 그 이유 때문이고, redis는 jwt를 관리하기 위함이다.

 

1. 환경 설정

https://blog.naver.com/jhi990823/222509215989

위 게시글을 따라가면서 docker 위에 redis를 올리면 된다.

 

https://blog.naver.com/jhi990823/222505297298

이후엔 이 게시글을 참조해서 redis를 사용할 수 있는 함수를 만든다.

 

build.gradle

	implementation 'org.springframework.boot:spring-boot-starter-security'
	implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'

 

application.properties

spring.jwt.secret=c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWppd29vbi1zcHJpbmctYm9vdC1zZWN1cml0eS1qd3QtdHV0b3JpYWwK

spring.redis.host=127.0.0.1
spring.redis.port=6379

 

jwt secret key는 HS512 알고리즘을 이용하기 떄문에 64byte 이상의 key를 이용한다.

 

그리고 member 정보는 mysql에 넣을 것이기 때문에 그쪽 설정도 맞춰오면 된다.

 

 

 


 

 

 

2. 구현 로직

먼저 token에 대해 간단하게만 짚고 넘어가고자 한다. token은 사용자 정보, 권한 등이 들어 있는 암호화된 정보이다. access token, refresh token 둘 다 같은 jwt이지만 로그인 유지를 위해 refresh token은 오직 재발급을 위해서만 사용되고, 만료시간도 길다. 반면 access token은 api를 따 오는 것이 목적이기 때문에 만료시간이 짧다.

먼저 토큰을 발급(로그인)받으면 access token, refresh token을 client에서 가지고 있는다. 이후 api 요청은 access token을 이용해서 인증받는다. access token이 만료되면 refresh token을 이용해 access token을 재발급받는다. 이게 기본 로직이다.

 

1) 회원가입

요청이 있으면 id, pw, 권한, 등등 정보를 db에 넣어줄 것임.

 

2) 로그인(토큰 발급)

id/pw 검증 이후 access token + refresh token 발급.

 

3) 토큰 재발급

access token + refresh token으로 요청하면 검증 이후 새 access token + refresh token 발급.

 

4) api 접근

access token 검증

 

 

 


 

 

 

3. Entity 설계

Spring Security 내부에서 UserDetail이라는 사용자 정보를 담는 class가 있고, 또 이를 구현한 User라는 class를 사용하기 때문에 class인 User, entity인 User 2개 사이에서 대환장을 맛보고 싶지 않다면 entity 이름은 Member로 짓길 권장한다.

 

entity/Member.java

@Entity
@Getter
@NoArgsConstructor
@Table(
    indexes = @Index(name="member_index", columnList = "member_set_id")
)
public class Member {
    @Id
    @Column(name = "member_id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name="member_set_id", length=20, nullable = false, unique = true)
    private String memberId;

    @Column(length=100, nullable = false)
    private String password;

    @Column(length=30)
    private String name;

    @Column
    private Boolean license;

    @Column(length=15)
    private String phoneNumber;

    @Enumerated(EnumType.STRING)
    private UserRole userRole;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="customer_company_id")
    private CustomerCompany customerCompany;

    public void setRelationWithCustomerCompany(CustomerCompany customerCompany){
        this.customerCompany = customerCompany;
    }

    @Builder
    public Member(String memberId, String password, Boolean license, String phoneNumber, UserRole userRole, String name){
        this.memberId = memberId;
        this.password = password;
        this.license = license;
        this.phoneNumber = phoneNumber;
        this.userRole = userRole;
        this.name = name;
    }

    public UsernamePasswordAuthenticationToken toAuthentication(){
        return new UsernamePasswordAuthenticationToken(memberId, password);
    }
}

Member의 경우 index로 memberId라는 것으로 이용하는데 이는 사용자가 입력하는 id이다. 이걸로 검색을 많이 하는 만큼 index를 만드는 것이 검색 효율을 높여줄 것이다.

toAuthentication은 memberId와 password를 이용해서 Spring Security 내부에 있는 UsernamePasswordAuthenticationToken이라는 것을 만들어 주어야 토큰이 발급되는데, 이를 여기서 진행한다.

userRole의 경우에는 사용자가 어떤 권한인지 확인해 주는 enumerated class이다.

CustomerCompany의 경우 member : customercompany = n : 1의 관계로, 속해있는 그룹이다.

 

entity/UserRole.java

public enum UserRole {
    ROLE_NOT_PERMITTED, ROLE_USER, ROLE_MANAGER, ROLE_ADMIN
}

이 UserRole은 정확한 형식에 맞추어야 한다.

 

entity/MemberRepository.java

@Repository
public interface MemberRepository extends JpaRepository<Member, Long>{
    Optional<Member> findById(Long id);
    Optional<Member> findByMemberId(String memberId);
    boolean existsByMemberId(String memberId);
    Optional<Member> findByPhoneNumber(String phoneNumber);
    @Query("SELECT m FROM Member m JOIN FETCH m.customerCompany WHERE m.memberId = :member_id")
    Optional<Member> findByMemberIdWithCustomerCompany(@Param("member_id") String memberId);
}

JpaRepository를 상속받는다. memberId에 해당하는 사용자가 있는지 검사 및 탐색을 위해 findByMemberId, existsByMemberId, 휴대폰 번호로 사용자를 찾기 위해 findByPhoneNumber, 그리고 fetch join으로 customerCompany도 불러오기 위해 findByMemberWithCustomerCompany를 가져왔다.

 

 

 


 

 

 

4. JWT 및 Security 설정

이제 jwt 설정을 해 줄 것이다.

 

/security/jwt/JwtUtil.java

@Component
public class JwtUtil {
    private static final String AUTHORITIES_KEY = "auth";
    private static final String BEARER_TYPE = "bearer";
    private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30;            // 30분
    private static final long REFRESH_TOKEN_EXPIRE_TIME = 1000 * 60 * 60 * 24 * 7;  // 7일

    private final Key key;

    public long getRefreshTokenExpireTime(){
        return this.REFRESH_TOKEN_EXPIRE_TIME;
    }

    // spring.jwt.util에 있는 값으로 key를 decode하는 함수
    public JwtUtil(@Value("${spring.jwt.secret}") String secretKey){
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

    // 입력받은 token을 parse해서 값을 가져오는 함수
    private Claims parseClaims(String accessToken){
        try{    
            return Jwts.parserBuilder()
                        .setSigningKey(key)
                        .build()
                        .parseClaimsJws(accessToken)
                        .getBody();
        }
        catch (ExpiredJwtException e){
            return e.getClaims();
        }
    }

    // access token, refresh token을 생성하는 함수
    public TokenDto generateTokenDto(Authentication authentication){

        String authorities = authentication.getAuthorities().stream()
                                .map(GrantedAuthority::getAuthority)
                                .collect(Collectors.joining(","));

        long now = (new Date()).getTime();

        Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
        String accessToken = Jwts.builder()
                                    .setSubject(authentication.getName())
                                    .claim(AUTHORITIES_KEY, authorities)
                                    .setExpiration(accessTokenExpiresIn)
                                    .signWith(key, SignatureAlgorithm.HS512)
                                    .compact();
                                
        Date refreshTokenExpiresIn = new Date(now + REFRESH_TOKEN_EXPIRE_TIME);
        String refreshToken = Jwts.builder()
                                    .setExpiration(refreshTokenExpiresIn)
                                    .signWith(key, SignatureAlgorithm.HS512)
                                    .compact();

        return TokenDto.builder()
                        .grantType(BEARER_TYPE)
                        .accessToken(accessToken)
                        .accessTokenExpiresIn(accessTokenExpiresIn.getTime())
                        .refreshToken(refreshToken)
                        .build();
    }

    // jwt token을 복호화해서 권한 정보를 확인하는 함수
    public Authentication getAuthentication(String accessToken){
        Claims claims = parseClaims(accessToken);

        if(claims.get(AUTHORITIES_KEY) == null){
            throw new CustomException(ErrorCode.NOT_ALLOWED);
        }

        Collection<? extends GrantedAuthority> authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
                                                                                    .map(SimpleGrantedAuthority::new)
                                                                                    .collect(Collectors.toList());
                
        UserDetails principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, "", authorities);

    }

    // token을 검증하는 함수
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            System.out.println("잘못된 JWT 서명입니다.");
        } catch (ExpiredJwtException e) {
            System.out.println("만료된 JWT 토큰입니다.");
        } catch (UnsupportedJwtException e) {
            System.out.println("지원되지 않는 JWT 토큰입니다.");
        } catch (IllegalArgumentException e) {
            System.out.println("JWT 토큰이 잘못되었습니다.");
        }
        return false;
    }
}

access token의 경우에 authentication.getName()을 하는데, 이는 Member Entity에 있는 name이 아니라 Member Entity에서 UsernamePasswordAuthenticationToken에 넣은 memberId값이다. access token의 경우에는 사용자 정보를 담지만 refresh token에는 재발급을 위한 signature 정보만 들어가 있다.

 

/security/jwt/JwtUtil.java

@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter{
    public static final String AUTHORIZATION_HEADER = "Authorization";
    public static final String BEARER_PREFIX = "Bearer ";

    private final JwtUtil jwtUtil;

    private String resolveToken(HttpServletRequest request){
        String token = request.getHeader(AUTHORIZATION_HEADER);
        if(StringUtils.hasText(token) && token.startsWith(BEARER_PREFIX)){
            return token.substring(7);
        }
        return null;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
                                    throws IOException, ServletException{
        String bearerToken = this.resolveToken(request);

        if(StringUtils.hasText(bearerToken) && jwtUtil.validateToken(bearerToken)){
            Authentication authentication = jwtUtil.getAuthentication(bearerToken);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }

        filterChain.doFilter(request, response);
    }
}

OncePerRequestFilter는 request filter당 딱 1번 수행되게만 해주는 필터(servlet 전에 있는 그 filter이다)이다. 이 filter의 구현은 doFilterInternal이라는 함수를 override하면 된다. 이 filter에서는 token을 풀어헤쳐서 필요한 정보만 따 온다. 여기서는 token 내부에 있는 authentication(권한)정보를 Security Context 내부에 저장한다. 이렇게 저장하는 이유는 authentication 정보에 따라 접근할 수 있는 페이지를 다르게 둘 것이기 때문에 이를 저장하는 과정이 필요하다.

 

/security/config/JwtSecurityConfig.java

@AllArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity>{
    private final JwtUtil jwtUtil;

    @Override
    public void configure(HttpSecurity http){
        JwtFilter customJwtFilter = new JwtFilter(jwtUtil);
        http.addFilterBefore(customJwtFilter, UsernamePasswordAuthenticationFilter.class);
    }
}

직전에 만든 JwtFilter를 Security Filter 앞에 추가해 준다.

 

/security/exception/JwtAcceessDeniedHandler.java

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler{
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedHandler)
                        throws IOException, ServletException{
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        PrintWriter out = response.getWriter();
        out.print(ErrorResponse.toJson(ErrorCode.NOT_ALLOWED).toString());
        out.flush();

    }
}

권한에 맞지 않는 접근을 했을 경우(ex. 일반 사용자가 관리자 페이지로 접근) 정해져 있는 양식에 따라 error를 리턴해 준다.(forbidden)

 

/security/exception/JwtAuthenticationEntryPoint.java

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint{
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
                    throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        PrintWriter out = response.getWriter();
        out.print(ErrorResponse.toJson(ErrorCode.NO_UNAUTHORIZED).toString());
        out.flush();

    }
}

마찬가지이다. 여기서는 사용자가 인증에 실패할 경우 unauthorized를 리턴해 준다.

 

/security/exception/SecurityConfig.java

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter{
    private final JwtUtil jwtUtil;
    private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;

    @Bean
    public static PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception{
        http
            .httpBasic().disable()
            .csrf().disable()
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                .authorizeRequests()
                    .antMatchers("/member/**").permitAll()
                    .antMatchers("/manage/**", "/dashboard/**").hasRole("MANAGER")
                    .antMatchers("/kickboard/**").hasAnyRole("USER", "MANAGER")
            .and()
                .authorizeRequests()
                .anyRequest()
                .authenticated()
            .and()
                .exceptionHandling()
                    .authenticationEntryPoint(jwtAuthenticationEntryPoint)
                    .accessDeniedHandler(jwtAccessDeniedHandler)
            .and()
                .apply(new JwtSecurityConfig(jwtUtil));
    }
}

거의 main class이다. 직전까지 만든 것들을 이용해서 사용자가 접근할 수 있는 page를 정해 준다. /member/ url은 회원가입/로그인 등등이 있으니 모든 사용자가 접근할 수 있게 정해두고, /manage나 /dashboard는 관리자 페이지이기 때문에 manager만 접근 가능하게 둔다. /kickboard는 일반 사용자만 이용할 수 있게 둔다.

이외의 모든 request는 인증된 사용자만 이용할 수 있게 둔다.

예외처리의 경우, 직전에 만든 handler를 사용한다.

마지막으로 JwtFilter를 적용한다.

 

/security/util/SecurityUtil.java

public class SecurityUtil {
    public SecurityUtil() {}

    public static String getCurrentMemberId(){
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if(authentication == null || authentication.getName() == null){
            throw new CustomException(ErrorCode.NO_UNAUTHORIZED);
        }
        return authentication.getName();
    }
}

security util class는 security context에 저장한 authentication 정보를 꺼내오고, 이 정보가 없다면 인증되지 않은 사용자이므로 customException을 발생시킨다. 아니라면 member id를 리턴한다.

 

 

 


 

 

 

5. 인증 과정

지금까지 만든 class들을 사용해 인증 및 jwt token 발급을 해 볼 것이다.

 

/dto/MemberDto.java

@Data
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MemberDto {
    private String id;
    private String name;
    private String password;
    private Boolean license;
    private UserRole user_role;
    private String phone_number;
    private String company_name;
    private String company_code;

    public Member toEntity(PasswordEncoder passwordEncoder){
        return Member.builder()
                        .memberId(id)
                        .password(passwordEncoder.encode(password))
                        .name(name)
                        .license(license)
                        .phoneNumber(phone_number)
                        .userRole(user_role)
                        .build();
    }

    public UsernamePasswordAuthenticationToken toAuthentication(){
        return new UsernamePasswordAuthenticationToken(id, password);
    }
}

회원가입, 로그인에서 사용할 dto이다.

 

/dto/TokenDto.java

@Data
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TokenDto {
    String grantType;
    String accessToken;
    Long accessTokenExpiresIn;
    String refreshToken;
}

token 발급/전송을 위한 dto이다.

 

/controller/AuthController.java

@RestController
@RequiredArgsConstructor
public class AuthController {
    @Autowired private AuthService authService;
    @Autowired private MemberService memberService;

    @PostMapping("/member/signup")
    public ResponseEntity<Object> signup(@RequestBody MemberDto memberDto){
        authService.signup(memberDto);

        JSONObject returnObject = new JSONObject();
        returnObject.put("msg", "Success");
        return new ResponseEntity<Object>(returnObject.toString(), HttpStatus.CREATED);
    }

    @PostMapping("/member/login")
    public ResponseEntity<Object> login(@RequestBody MemberDto memberDto){
        return new ResponseEntity<Object>(authService.login(memberDto), HttpStatus.OK);
    }

    @PostMapping("/member/reissue")
    public ResponseEntity<Object> reissue(@RequestBody TokenDto tokenDto){
        return new ResponseEntity<Object>(authService.reissue(tokenDto), HttpStatus.OK);
    }

    @GetMapping("/member/me")
    public ResponseEntity<Object> currentMemberInfo(){
        
        Member member = memberService.getCurrentMemberInfo();
        MemberDto memberDto = MemberDto.builder()
                                        .id(member.getMemberId())
                                        .name(member.getName())
                                        .license(member.getLicense())
                                        .user_role(member.getUserRole())
                                        .phone_number(member.getPhoneNumber())
                                        .company_name(member.getCustomerCompany().getCompanyName())
                                        .build();
                                        
        return new ResponseEntity<Object>(memberDto, HttpStatus.OK);
    }

}

dto <-> entity 전환은 controller에서 하고 싶었지만 dto의 password를 암호화하는 과정에서, passwordEncoder를 controller에서 설정하면 비밀번호 암호화/복호화에서 문제가 생겼다. 왜 그러는지는 모르겠다. 그래서 dto를 service까지 넘겨서 사용했다.</->

 

/service/CustomUserDetailService.java

@Service
@RequiredArgsConstructor
public class CustomUserDetailService implements UserDetailsService{
    @Autowired private MemberRepository memberRepository;
    @Autowired private GetWithNullCheck getWithNullCheck;

    private UserDetails createUserDetails(Member member){
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getUserRole().toString());
        System.out.println(grantedAuthority.getAuthority());
        return new User(String.valueOf(member.getMemberId()), member.getPassword(), Collections.singleton(grantedAuthority));
    }

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException{

        return memberRepository.findByMemberIdWithCustomerCompany(username)
                                    .map(this::createUserDetails)
                                    .orElseThrow(()-> new UsernameNotFoundException(username + " -> db에 없음"));
                                         
       
    }
}

UserDetailSerive의 구현체, loadUserByUsername이라는 class를 override한다. 여기서 spring security 내부에 있는 user class를 리턴한다.

 

/service/AuthService.java

@Service
@RequiredArgsConstructor
public class AuthService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    @Autowired private MemberRepository memberRepository;
    @Autowired private CustomerCompanyRepository customerCompanyRepository;
    private final JwtUtil jwtUtil;
    private final PasswordEncoder passwordEncoder;
    @Autowired private final RedisUtil redisUtil;

    @Transactional
    public void signup(MemberDto memberDto){
        CustomerCompany customerCompany = customerCompanyRepository.findByCustomerCompanyCode(memberDto.getCompany_code());
        if(memberDto.getId() == null || memberDto.getPassword() == null || memberDto.getCompany_code() == null || customerCompany == null){
            throw new CustomException(ErrorCode.PARAMETER_NOT_VALID);
        }
        if(memberRepository.existsByMemberId(memberDto.getId())){
            throw new CustomException(ErrorCode.ID_DUPLICATED);
        }
        Member member = memberDto.toEntity(passwordEncoder);
        
        member.setRelationWithCustomerCompany(customerCompany);

        memberRepository.save(member);
    }

    @Transactional
    public TokenDto login(MemberDto memberDto){
        UsernamePasswordAuthenticationToken authenticationToken = memberDto.toAuthentication();
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        TokenDto tokenDto = jwtUtil.generateTokenDto(authentication);

        redisUtil.set(authentication.getName(), tokenDto.getRefreshToken(), jwtUtil.getRefreshTokenExpireTime());
        
        return tokenDto;
    }

    @Transactional
    public TokenDto reissue(TokenDto tokenDto){
        if(!jwtUtil.validateToken(tokenDto.getRefreshToken())){
            throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
        }

        Authentication authentication = jwtUtil.getAuthentication(tokenDto.getAccessToken());

        String refreshToken = redisUtil.get(authentication.getName());
        if(refreshToken == null){
            new CustomException(ErrorCode.MEMBER_STATUS_LOGOUT);
        }

        if(!refreshToken.equals(tokenDto.getRefreshToken())){
            throw new CustomException(ErrorCode.INVALID_REFRESH_TOKEN);
        }

        TokenDto returnTokenDto = jwtUtil.generateTokenDto(authentication);

        redisUtil.set(authentication.getName(), returnTokenDto.getRefreshToken(), jwtUtil.getRefreshTokenExpireTime());

        return returnTokenDto;
    }
}

사실상 대부분 로직이 있는 class이다.

signup의 경우에는 parameter를 검증하고, member entity의 password를 encoding한 후 repository에 저장한다.

login의 경우에는 memberDto의 toAuthentication 함수를 이용해 UsernamePasswordAuthenticationToken을 가져오고, 권한을 가져오고, token을 발급한다. 여기서 AuthenticationManager가 spring security에서 실제로 인증을 해 주는 class이다. 만약 잘 로그인이 되었다면 redis에 id에 해당하는 refresh token을 저장하고, 만료 시간을 설정해 준다.

reissue의 경우에는 refresh token을 검증하고, authentication 객체를 가져온 이후 redis에서 refresh token을 가져온다. 없거나 값이 이상하면 error를 일으키게 된다. 검증되었다면 새로운 token을 발급받고, refresh token을 redis에 넣는다.

 

끝!

 

이후 access token을 사용하는 방식은, http header의 Authorization에 Bearer ~~~~~~~~ 이렇게 넣어주면 된다

 

ex)

# Request
GET http://localhost:8080/member/me
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIyIiwiYXV0aCI6IlJPTEVfVVNFUiIsImV4cCI6MTYxNTExNDI4MH0.43LvabP41Awhicy6YYAYHtDPnxNYpEygtE-DjLaDjNpAxZf01Nx4xE_dGk0V4jBpjwCgKVGKZIMyEeIppwzARQ

# Response
{
  "email": "test@test.net"
}