본문 바로가기
Backend/Spring

[Spring] Spring Security JWT 로그인 구현 (HTTP Basic Authentication / Form Based Authentication / JWT)

by 비전공자 기록광 2023. 1. 17.
반응형

스프링 시큐리티와 JWT를 이용한 로그인을 구현해보려 한다.

 

 

➡️ 개념 정리

2023.01.09 - [Backend/Spring] - [Spring] Spring Security 기본 개념 (JWT / OAuth2.0 / 동작 방식 / 구성 요소)

 

[Spring] Spring Security 기본 개념 (JWT / OAuth2.0 / 동작 방식 / 구성 요소)

JWT (Jason Web Token) 유저 인증, 식별하기 위한 토큰 기반의 인증 구조 헤더 (Header) 타입 (type) : 항상 JWT 알고리즘 (alg) 페이로드 (Payload) : 사용자 정보 담김 서명 (Verify Signature) 동작 방식 1. 클라이언

datamoney.tistory.com

 

 

그 전에 HTTP Basic Authentication과 Form Based Authentication을 정리해봤다.

 

HTTP Basic Authentication

특정 리소스에 접근 요청할 때 브라우저가 사용자에게 username, password를 확인해 제한하는 인증 방법

  • 동작 방법
    • 1. 인증되지 않은 유저가 제한된 요청 보냄
    • 2. 서버는 username, password 요청
    • 3. 클라이언트는 username, password을 담아 다시 요청
    • 4. 일치하면 200 코드 실패하면 401 에러
  •  특징
    • 구현 간단
    • 쿠키, 세션 필요하지 않음
    • 에러가 나면 401 에러 보냄
    • logout 제공 안함
    • 보안에 취약
      • HTTPS와 사용 필수
      • 민감한 리소스 사용하지 않아야 함

 

Form Based Authentication

사용자가 입력한 데이터를 POST방식으로 전달해 인증하는 방법

  • 동작 방법
    • 1.인증되지 않은 유저가 제한된 요청 보냄서버는 login page로 보냄
    • 2. 클라이언트는 username, password을 담아 POST방식으로 다시 요청
    • 3.서버에서 인증한 후 session id 생성해 클라이언트에 보내줌
    • 4. 클라이언트는 접근 제한된 리소스 요청 때마다 쿠키 같이 전달
  • 특징
    • 가장 흔히 쓰이는 인증 방법
    • SPA 보다 SSR에서 많이 사용
    • 데이터 보호를 위해 SSL, HTTPS와 함께 사용해야 함
    • public REST endpoints app에는 맞지 않음

 

 

내 프로젝트는 Vue.js와 Spring Boot를 사용하기에 JWT 인증을 선택했다.

 

 

 

의존성을 추가해준다.

 

 

Maven

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

 

 

Gradle

dependencies {
    compileOnly "org.springframework.boot:spring-boot-starter-security"
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

 

UserController

login 요청을 받는 메소드를 만들어줬다. 요청이 들어오면 username과 password을 서비스단의 login 메소드로 넘겨준다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/api/user")
public class UserController {
 
    private final UserService service;
 
    public UserController(UserService service) {
        this.service = service;
    }
 
    @PostMapping("/login")
    public ResponseEntity<JwtToken> loginSuccess(@RequestBody Map<StringString> loginForm) {
        JwtToken token = service.login(loginForm.get("username"), loginForm.get("password"));
        return ResponseEntity.ok(token);
    }
}
cs

 

 

UserService

login요청이 들어오면 같이 온 유저 정보 email, password로 인증 과정을 진행한다.

Username과 Password 기반의 Authentication Token 객체를 생성해

검증 과정을 진행한다.

그리고 그 검증된 인증 정보로 JWT 토큰을 생성한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Service
@Transactional
public class UserService {
 
    private final BCryptPasswordEncoder encoder;
    private final UserRepository repository;
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    private final JwtTokenProvider jwtTokenProvider;
 
    public UserService(BCryptPasswordEncoder encoder, UserRepository repository, AuthenticationManagerBuilder authenticationManagerBuilder, JwtTokenProvider jwtTokenProvider) {
        this.encoder = encoder;
        this.repository = repository;
        this.authenticationManagerBuilder = authenticationManagerBuilder;
        this.jwtTokenProvider = jwtTokenProvider;
    }
 
    public JwtToken login(String email, String password) {
        // Authentication 객체 생성
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(email, password);
        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
 
        // 검증된 인증 정보로 JWT 토큰 생성
        JwtToken token = jwtTokenProvider.generateToken(authentication);
 
        return token;
    }
}
cs

 

사실 로그인 요청이 들어오기 전부터 해줘야하는 게 있다. 

HTTP 요청이 들어오면 Spring Security는 요청에 대한 Security Filter 처리를 해줘야 한다.

 

 

SecurityConfig

위에서 말했듯 HTTP 요청이 들어오고 이후의 설정을 여기서 해준다.

기존에는 WebSecurityConfigurerAdapter을 상속받아서 SecurityFilterChain을 @Override해서 썼는데 현재는 deprecated되었다. 이제는 @Bean으로 등록해서 모두 컨테이너에서 관리하도록 해준다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Configuration
@EnableWebSecurity
public class SecurityConfig { //WebSecurityConfigurerAdapter was deprecated
 
    private final JwtTokenProvider jwtTokenProvider;
 
    public SecurityConfig(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
 
 
    @Bean
    public BCryptPasswordEncoder encoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .formLogin().disable()
                .httpBasic().disable()
                .authorizeRequests()
                .antMatchers("/api/user").permitAll()
                .and()
                .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }
}
 
cs

 

- csrf( ).disable( )

csrf (cross site request forgery) : 사이트간 위조 요청
인증된 사용자의 토큰을 탈취해 위조된 요청을 보냈을 경우 파악해 방지하는 것

 

 disable 이유?
rest api 에서는 권한이 필요한 요청 위해서 인증 정보를 포함시켜야 함
서버에 인증정보 저장하지 않기 때문에 작성할 필요 없음
(JWT를 쿠키에 저장하지 않기 때문)

 

 

- sessionManagement( ).sessionCreationPolicy(SessionCreationPolicy.STATELESS)

JWT를 사용하기 때문에 세션도 사용하지 않는다. 

 

 

- formLogin( ).disable( ) & httpBasic( ).disable( )

HTTP Basic Authentication과 Form Based Authentication을 사용하지 않는다.

 

 

- antMatchers("/api/user").permitAll( )

해당 요청에 관해 모두 접근가능하게 한다.

 

 

- addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)

UsernamePasswordAuthenticationFilter에 가기 전에 직접 만든 JwtAuthenticationFilter을 실행하겠다.

 

 

이렇게 설정을 해준다면

서버가 실행되고 모든 HTTP 요청이 이 SecurityConfig로 들어온다.

여기서 ("/api/user") 라는 요청이 있으면 모두 접근 가능하게 해준다.

 

그렇게 되면 아까 위에서 만들어준 controller의 login 요청으로 가게 될 것이다.

 

그리고 서비스 단으로 넘어가면 본격적인 인증 과정을 거친다.

여기서 Authentication 객체를 만들어주는데

이 객체가 UsernamePasswordAuthenticationFilter와 관계 있기 때문에

먼저 JwtAuthenticationFilter(jwtTokenProvider) 가 실행될 것 이다.

 

 

JwtTokenProvider

JwtAuthenticationFilter에서 필요한 JwtTokenProvider을 만들어 @Component로 등록해줬다.

일단 다시 JWT 동작 방법에 대해 보자면

 

1. 클라이언트측에서부터 서버측으로 JWT 받음
2. 서버측의 비밀 값과 JWT의 헤더, 페이로드를 alg에 넣고 서명값과 같은지 확인
3. 같다면 유저에 인가

 

이런식으로 진행된다.

넘어올 값으로 인증 과정을 진행하기 위해서는 서버 측의 secret key가 필요하다.

이 설정은 application.yaml에 작성해줘서 @Value로 처리해 가져와 쓸 수 있다.

 

jwt:
  secret: fske256d3433kf2@er454ddd!35435rd!!!dkzfefdsdfjldkjf!dxfd5dsfx2432dszdfdsfsfs12xdfds2sa

문자, 숫자와 특수문자를 적절히 섞어 만들어준다. 짧으면 에러가 남..

복호화에서 사용할 알고리즘이 HS256이고 사용하기 위해 키가 256비트 이상이 되어야 한다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Slf4j
@Component
public class JwtTokenProvider {
 
    private final Key key;
 
    public JwtTokenProvider(@Value("${jwt.secret}"String secretKey) {
        byte[] secretByteKey = DatatypeConverter.parseBase64Binary(secretKey);
        this.key = Keys.hmacShaKeyFor(secretByteKey);
    }
 
    public JwtToken generateToken(Authentication authentication) {
        String authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .collect(Collectors.joining(","));
 
        //Access Token 생성
        String accessToken = Jwts.builder()
                .setSubject(authentication.getName())
                .claim("auth", authorities)
                .setExpiration(new Date(System.currentTimeMillis()+ 1000 * 60 * 30))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
 
        //Refresh Token 생성
        String refreshToken = Jwts.builder()
                .setExpiration(new Date(System.currentTimeMillis()+ 1000 * 60 * 60 * 36))
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
 
        return JwtToken.builder()
                .grantType("Bearer")
                .accessToken(accessToken)
                .refreshToken(refreshToken)
                .build();
    }
 
    public Authentication getAuthentication(String accessToken) {
        //토큰 복호화
        Claims claims = parseClaims(accessToken);
 
        if (claims.get("auth"== null) {
            throw new RuntimeException("권한 정보가 없는 토큰입니다.");
        }
 
        Collection<extends GrantedAuthority> authorities =
                Arrays.stream(claims.get("auth").toString().split(","))
                        .map(SimpleGrantedAuthority::new)
                        .collect(Collectors.toList());
 
        UserDetails principal = new User(claims.getSubject(), "", authorities);
        return new UsernamePasswordAuthenticationToken(principal, "", authorities);
    }
 
    public boolean validateToken(String token) {
        try {
            Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
            return true;
        }catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            log.info("Invalid JWT Token", e);
        } catch (ExpiredJwtException e) {
            log.info("Expired JWT Token", e);
        } catch (UnsupportedJwtException e) {
            log.info("Unsupported JWT Token", e);
        } catch (IllegalArgumentException e) {
            log.info("JWT claims string is empty.", e);
        }
        return false;
    }
 
    private Claims parseClaims(String accessToken) {
        try {
            return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
        } catch (ExpiredJwtException e) {
            return e.getClaims();
        }
    }
}
 
cs

 

아까 UserService에서 Authentication 객체로 JWT를 만들기 위해 

jwtTokenProvider.generateToken(authentication)을 불렀다.

 

generateToken으로 AccessToken과 RefreshToken을 만들어준다.

AccessToken은 만료 시간을 30분으로, RefreshToken는 3일로 만들어줬다.

 

이렇게 만들어 요청에 응답해주기 위해

토큰정보 DTO를 만들어줬다.

 

 

JwtToken

1
2
3
4
5
6
7
8
9
10
@Builder
@Data
@AllArgsConstructor
public class JwtToken {
 
    private String grantType;
    private String accessToken;
    private String refreshToken;
}
 
cs

generateToken 메소드로 만들어진 토큰은 빌더패턴으로 JwtToken에 담아줬다.

 

 

JwtAuthenticationFilter

Jwt 인증을 위해 생성되는 토큰이다. 요청과 함께 바로 실행이 된다고 보면 된다.

요청이 들어오면 헤더에서 토큰을 추출한다.

 

그리고 유효성 검사를 실행한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class JwtAuthenticationFilter extends GenericFilterBean {
 
    private final JwtTokenProvider jwtTokenProvider;
 
    public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
        this.jwtTokenProvider = jwtTokenProvider;
    }
 
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = resolveToken((HttpServletRequest) request);
 
        // 토큰 유효성 검사
        if (token!=null && jwtTokenProvider.validateToken(token)) {
            Authentication authentication = jwtTokenProvider.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
        chain.doFilter(request, response);
    }
 
    // 헤더에서 토큰 추출
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}
cs

 

일단  Authorization Bearer임을 확인한다.

accessToken을 가져와 토큰을 다시 복호화하고 모든 검증이 끝나면 토큰을 인증받은 유저인 UsernamePasswordAuthenticationToken 을 리턴해준다. 

 

이 과정이 끝나면 이 유저는 토큰이 유효한 유저임이 증명이 되고 SecurityContext에 저장이 된다.

 

 

요청을 한번 해본다.

 

 

요청

 

응답

 

JWT 토큰 응답도 잘해주고 복호화해보니 값도 잘 맞는 걸 볼 수 있다.


코드

https://github.com/recordbuffer/Custom-er

 

GitHub - recordbuffer/Custom-er: 커스텀머-고객이 원하는 이미지로 직접 디자인해서 구매할 수 있는 사

커스텀머-고객이 원하는 이미지로 직접 디자인해서 구매할 수 있는 사이트. Contribute to recordbuffer/Custom-er development by creating an account on GitHub.

github.com

 

참고

[Spring] Spring Security + JWT 토큰을 통한 로그인 — 오늘의 기록 (tistory.com)

 

[Spring] Spring Security + JWT 토큰을 통한 로그인

JWT JWT(Json Web Token)은 일반적으로 클라이언트와 서버 통신 시 권한 인가(Authorization)을 위해 사용하는 토큰이다. 현재 앱개발을 위해 REST API를 사용 중인데, 웹 상에서 Form을 통해 로그인하는 것이

gksdudrb922.tistory.com

 

반응형

댓글