[Spring] jwt와 security를 이용한 로그인

Bong2·2024년 7월 4일

Spring

목록 보기
6/9

Spring 버전 : 3.xx 이상
Security 버전 : 6.3


그림 출처
1. Filter chain : 다양한 필터로 이루어져있다. 하지만 우리가 이용할 것은 UsernamePasswordAuthenticationFilter로 사용자인증정보를 처리
2. UsernamePasswordAuthenticationToken :위에서 언급한 Filter에서 토큰을 만들어서 3번의 AuthenticationManager에 보내준다.
3. AuthenticationManager : 실제 인증을 실행, AuthenticationProvider들을 이용한다.
4. AuthenticationProvider : 각각의 Provider들은 특정 유형의 인증을 처리
5. PasswordEncoder : 인증과 인가에서 사용될 패스워드의 인코딩방식을 설정
6. UserDetailsService : 사용자의 정보를 가져온다. 사용자의 정보를 받아 loadByUsername을 호출하여 데이터베이스에서 관련 정보를 담은 UserDetail를 반환
7. UserDetails : 사용자의 아이디, 비밀번호 등의 정보를 가지고 있다.
8. SecurityContextHolder : Security Context에 접근권한 설정, 현재 실행 중인 스레드 세션에 접근

//사용자 정보를 활용
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(memberReq.getLogInId(), memberReq.getPassword()); 

JWT 관련 설정들

application.properties

# jwt
jwt.header = Authorization
// BASE64로 암호환 내용
jwt.secret= dyAeHubOOc8KaOfYB6XEQoEj1QzRlVgtjNL8PYs1A1tymZvvqkcEU7L1imkKHeDa 
# unit is ms. 15 * 24 * 60 * 60 * 1000 = 15days
jwt.expiration=1296000000

build.gradle

	//jwt
	implementation("io.jsonwebtoken:jjwt-api:0.11.5")
	runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
	runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")

JWT관련 처리 유틸리티 코드

JwtUtil : JWT유효성 검증, JWT 토큰생성, JWT의 Claims 추출

@Component
@Slf4j
public class JwtUtil {

    private final Key key;

    private final long accessTokenExpTime;
    // application.properties에서 secret 값 가져와서 key에 저장
    public JwtUtil(@Value("${jwt.secret}") String secretKey,
        @Value("${jwt.expiration}") long accessTokenExpTime)
    {
        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
        this.accessTokenExpTime = accessTokenExpTime;
    }

    //Access Token 생성
    public String createAccessToken(MemberReq member) {
        return createToken(member, accessTokenExpTime);
    }

    public String getLoginId(String Token)
    {
        return parseClaims(Token).get("LogInId",String.class);
    }

    private String createToken(MemberReq member, long expireTime) {
        Claims claims = Jwts.claims();
        claims.put("LogInId", member.getLogInId());
        claims.put("name", member.getName());

        ZonedDateTime now = ZonedDateTime.now();
        ZonedDateTime tokenValidity = now.plusSeconds(expireTime);

        return Jwts.builder()
                .setClaims(claims)
                .setIssuedAt(Date.from(now.toInstant()))
                .setExpiration(Date.from(tokenValidity.toInstant()))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    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("Invalid JWT Token" + e);
        } catch (ExpiredJwtException e) {
            System.out.println("Expired JWT Token"+ e);
        } catch (UnsupportedJwtException e) {
            System.out.println("Unsupported JWT Token" + e);
        } catch (IllegalArgumentException e) {
            System.out.println("JWT claims string is empty." + e);
        }
        return false;
    }

    public Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}

로그인 관련 소스

로그인 시 사용자정보들을 검증한 뒤 로그인 성공 시 JWT토큰 반환

Controller

로그인을 강제로 시도를 하기 위하여 밑의 코드처럼 설정

@PostMapping("/signin")
    public ResponseEntity<?> signIn(@RequestBody MemberReq memberReq)
    {
        UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                new UsernamePasswordAuthenticationToken(memberReq.getLogInId(), memberReq.getPassword());

        //현재 Request의 Security Context에 접근권한 설정
        SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
        //로그인 강제 시도
        String token = this.memberService.login(memberReq);
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add("Authorization","Bearer "+token);
        return new ResponseEntity<>(token,httpHeaders,HttpStatus.OK);
    }

Service

public String login(MemberReq memberReq){
        String loginId = memberReq.getLogInId();
        String password = memberReq.getPassword();

        Optional<Member> findMember =memberRepository.findByLogInId(loginId);

        //로그인 정보 확인
        if(findMember.isEmpty()) {
            throw new UsernameNotFoundException("로그인 정보가 존재하지 않습니다.");
        }

        Member member = findMember.get();

        if(!passwordEncoder.matches(password, member.getPassword()))
        {
            throw new BadCredentialsException("비밀번호가 일치하지 않습니다.");
        }

        return jwtUtil.createAccessToken(memberReq);
    }

Entity

@Getter
@Setter
@Entity
public class Member {

    //기본키, 자동으로 1씩증가시키기위함
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    Long userId;

    @Column(nullable = false, length = 30, updatable = false, unique = true)
    String logInId;

    @Column(nullable = false)
    String name;

    @Column(nullable = false)
    String password;

    //제품과 연결, 회원이 사라지면 해당 제품도 다같이 삭제
    @OneToMany(mappedBy = "member", cascade = CascadeType.REMOVE)
    List<Product> productList;

}

인가

Custom UserDetails, UserDetailsSevice

@Getter
@NoArgsConstructor
public class UserDetailsImpl implements UserDetails{

    @Autowired
    Member member;
    List<GrantedAuthority> roles = new ArrayList<>();

    public UserDetailsImpl(Member member) {
        super();
        this.member = member;
    }

    public Member getUser() {
        return this.member;
    }

    @Override
    public String getPassword() {
        return null;
    }

    @Override
    public String getUsername() {
        return member.getName();
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }

    @Override //해당 User의 권한을 리턴하는 곳
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.roles;
    }
    public void setAuthorities(List<GrantedAuthority> roles) {
        this.roles = roles;
    }
}
@Component
public class UserDetailServiceImpl implements UserDetailsService {
    @Autowired
    private MemberRepository memberRepository;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException {
        Member member = this.memberRepository.findByLogInId(loginId).orElseThrow(
                ()->new IllegalArgumentException("error"));
        if(member == null) {
            return null;
        } 
        UserDetailsImpl userDetailsImpl = new UserDetailsImpl(member);
        return userDetailsImpl;
    }
}

loadUserByUsername : JWT에서 추출한 정보와 데이터베이스의 사용자 정보가 일치하는지 확인
존재 -> UserPasswordAuthenticationToken만들 때 필요한 UserDetails을 반환
미존재 -> 오류반환

jwtAuthFilter

@RequiredArgsConstructor
public class jwtAuthFilter extends OncePerRequestFilter { // OncePerRequestFilter ->한번 실행 보장
    final private UserDetailsService userDetailsService;
    final private JwtUtil jwtUtil;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String authorizationHeader = request.getHeader("Authorization");

        //JWT토큰이 있는 경우
        if(authorizationHeader != null && authorizationHeader.startsWith("Bearer "))
        {
            String token = authorizationHeader.replace("Bearer ","");
            String jwt = token.substring(7);

            if(jwtUtil.validateToken(jwt))
            {
                String LogInId = jwtUtil.getLoginId(jwt);

                UserDetails userDetails = userDetailsService.loadUserByUsername(LogInId);

                if(userDetails != null)
                {
                    UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
                            new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());

                    //현재 Request의 Security Context에 접근권한 설정 *자동으로 설정해줌
                    SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);

                }
            }
        }

        //다음 필터로 이동
        filterChain.doFilter(request,response);
    }
}

SecurityConfig

@Configuration
@EnableWebSecurity //모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션
@AllArgsConstructor
public class SecurityConfig  {

    final private UserDetailsService userDetailsService;
    final private JwtUtil jwtUtil;
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests((authorizeHttpRequests) -> authorizeHttpRequests
                        .requestMatchers(HttpMethod.GET,"/api/product/list","/api/product/info/{productId:\\\\d+}")
                        .permitAll()
                        .requestMatchers("/api/member/**")
                        .permitAll()
                        .anyRequest().authenticated())

                .csrf(csrf -> csrf.disable());
        //세션 관리 상태 없음으로 구성, Spring Security가 세션 생성 or 사용 X
        http.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(
                SessionCreationPolicy.STATELESS));

        //JwtAuthFilterUsernamePasswordAuthenticationFilter 앞에 추가
        http.addFilterBefore(new jwtAuthFilter(userDetailsService, jwtUtil), UsernamePasswordAuthenticationFilter.class);

        //FormLogin, BasicHttp 비활성화
        http.formLogin((form) -> form.disable());
        http.httpBasic(AbstractHttpConfigurer::disable);

        return http.build();
    }

    @Bean//해당 메서드의 리턴되는 오브젝트를 IoC로 등록해줌
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}


로그인 테스트

postman을 이용해서 테스트를 했다.

그림과 같이 실제 로그인이 성공하여 jwt토큰정보를 출력해주는 걸 볼 수 있다.

해당 JWT가 내가 보낸 정보와 일치하는 지를 다시 확인

참고 문헌

profile
자바 백엔드 개발자로 성장하자

0개의 댓글