[Spring Boot & React] Polling 웹애플리케이션 만들기 - 2부
스프링(Spring)/[Spring Boot & React] Polling 웹애플리케이션

[Spring Boot & React] Polling 웹애플리케이션 만들기 - 2부

반응형

[Spring Boot & React] Polling 웹애플리케이션 만들기 - 2부


1부 : 프로젝트 생성 및 기본 도메인 모델과 레포지토리 생성

 

최종적으로 만들어지는 페이지는 아래와 같다.

 

 

 

지난 시간에 이어서, 이번에는 JWT 인증과 함께 Spring Security 구성 및 로그인과 회원가입에 대한 API를 만들어보도록 하겠다.

 

처음 접하면 상당히 내용이 어렵고 복잡해보인다. 일단 진행과정을 익히며 익숙해지도록 노력해보자!

 

 

Spring Security와 JWT를 통한 사용자 인증 구축하기


사용자가 웹 애플리케이션에 회원가입하고, 로그인할 수 있는 API를 만들 것이다.

우선 진행하기에 앞서, 어떤 식으로 인증과정을 설계할건지 요약해보자

 

- 새로운 사용자를 Full Name, UserName, Email, Password로 등록할 것이다.

- UserName/Email과 Password를 통해 로그인할 수 있는 API를 작성할 것이다. 이때, 사용자의 자격 증명이 확인되면, API는 JWT 인증 토큰을 생성해 Response 시 반환시켜준다. 클라이언트는 모든 JWT 토큰을 모든 Request 인증 Header에 보내면서 보호 자원에 접근하게 된다.

- 이때 Spring Security를 설정해 보호된 자원에 대한 접근을 제한시킬 것이다.
(로그인, 회원가입 등 정적 리소스에 대한 API는 모든 사용자가 접근 가능)
(설문조사 작성하는 API, 설문조사에 대한 투표는 인증된 사용자만 접근 가능)

- 클라이언트가 유효한 JWT 토큰이 없는데 보호 자원에 접근을 시도하면, 401 오류를 발생시키도록 Spring Security를 설정할 것이다.

- Admin과 User 권한을 나누어 서버의 자원을 보호할 것이다.
(Admin만 설문지를 생성할 수 있다)
(User만 설문지를 투표할 수 있다)

 

Spring Security 구현의 핵심은 config 패키지에서 이루어진다.

해당 패키지 안에 SecurityConfig를 생성하자

 

com/example/polls/config/SecurityConfig.java

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
package com.example.polls.config;
 
import com.example.polls.security.CustomUserDetailsService;
import com.example.polls.security.JwtAuthenticationEntryPoint;
import com.example.polls.security.JwtAuthenticationFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.BeanIds;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(
        securedEnabled = true,
        jsr250Enabled = true,
        prePostEnabled = true
)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    CustomUserDetailsService customUserDetailsService;
 
    @Autowired
    private JwtAuthenticationEntryPoint unauthorizedHandler;
 
    @Bean
    public JwtAuthenticationFilter jwtAuthenticationFilter() {
        return new JwtAuthenticationFilter();
    }
 
    @Override
    public void configure(AuthenticationManagerBuilder authenticationManagerBuilder) throws Exception {
        authenticationManagerBuilder
                .userDetailsService(customUserDetailsService)
                .passwordEncoder(passwordEncoder());
    }
 
    @Bean(BeanIds.AUTHENTICATION_MANAGER)
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
 
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
 
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors()
                    .and()
                .csrf()
                    .disable()
                .exceptionHandling()
                    .authenticationEntryPoint(unauthorizedHandler)
                    .and()
                .sessionManagement()
                    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                    .and()
                .authorizeRequests()
                    .antMatchers("/",
                        "/favicon.ico",
                        "/**/*.png",
                        "/**/*.gif",
                        "/**/*.svg",
                        "/**/*.jpg",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js")
                        .permitAll()
                    .antMatchers("/api/auth/**")
                        .permitAll()
                    .antMatchers("/api/user/checkUsernameAvailability""/api/user/checkEmailAvailability")
                        .permitAll()
                    .antMatchers(HttpMethod.GET, "/api/polls/**""/api/users/**")
                        .permitAll()
                    .anyRequest()
                        .authenticated();
 
        // Add our custom JWT security filter
        http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
 
    }
}
cs

아직 해당 코드 내부에서 사용되는 클래스가 구현이 안돼있기 때문에 오류 표시가 많을 것이다.

앞으로 차차 정의해나갈 것이다.

 

우선 현재 SecurityConfig 클래스를 보면 상당히 복잡하다. 사용되는 기능에 대해 하나하나 이해해보자.

 

 

1. @EnableWebSecurity

 

프로젝트에서 웹 보안을 가능하도록 해주는 기본 스프링 보안 어노테이션이다.

 

 

2. @EnableGlobalMethodSecurity

 

어노테이션에 기반한 ''메소드 레벨 보안''을 가능하도록 해준다. 다음 3가지 유형의 어노테이션을 제공해준다.

 

1) securedEnabled : @Secured 어노테이션으로 컨트롤러/서비스 메소드를 보호해줌

1
2
3
4
5
6
7
8
@Secured("ROLE_ADMIN")
public User getAllUsers() {}
 
@Secured({"ROLE_USER""ROLE_ADMIN"})
public User getUser(Long id) {}
 
@Secured("IS_AUTHENTICATED_ANONYMOUSLY")
public boolean isUsernameAvailable() {}
cs

2) jsr250Enabled : @RolesAllowed 어노테이션을 사용할 수 있도록 지원해준다. (권한 부여 결정)

1
2
@RolesAllowed("ROLE_ADMIN")
public Poll createPoll() {}  
cs

3) prePostEnabled : @PreAuthorize와 @PostAuthorize 어노테이션으로 액세스 제어 구문을 기반한 더 복잡한 표현을 가능하도록 해준다.

1
2
3
4
5
@PreAuthorize("isAnonymous()")
public boolean isUsernameAvailable() {}
 
@PreAuthorize("hasRole('USER')")
public Poll createPoll() {}
cs

 

 

3. WebSecurityConfigurerAdapter

 

스프링 보안의 WebSecurityConfigurer 인터페이스를 구현해주는 클래스다.

 

기본 보안 구성을 제공해주고, 다른 클래스가 이 클래스를 상속받아 해당 메소드를 오버라이딩하여 보안 구성을 커스터마이징할 수 있다.

 

현재 프로젝트에서 WebSecurityConfigurerAdapter는 사용자 정의 보안 구성을 제공하기 위해 메소드를 확장하여 오버라이딩 하는 모습이다.

 

 

4. CustomUserDetailsService

 

사용자 인증을 하거나, 다양한 역할 기반 검사를 수행하려면 Spring Security는 사용자 세부사항을 불러와야 한다.

 

이를 위해 UserDetailsService username이라는 이름을 기반으로 사용자를 로드하는 단일 메소드를 가진 인터페이스로 구성된다.

UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;

우리는 UserDetailsService 인터페이스를 구현하는 CustomUserDetailsService를 정의할 것이고, loadUserByname() 메소드를 위한 구현을 제공해야 한다.

 

loadUserByUsername() 메소드는 Spring Security가 다양한 인증과 역할 기반 검증을 수행하기 위해 사용하는 UserDetails 객체를 리턴해준다.

 

구현에 있어서, 우리는 또한 UserPrincipal 클래스를 UserDetails 인터페이스로 구현해 정의할 것이며, loadUserByUsername()로부터 UserPrincipal 객체를 리턴받는다.

 

 

 

5. JwtAuthenticationEntryPoint

 

해당 클래스는, 인증이 이루어지지 않은 상태에서 보호 자원에 접근 시도가 들어오면 클라이언트에게 401 에러를 반환하는데 사용한다. 이는 Spring Security의 AuthenticationEntryPoint 인터페이스에서 구현한다.

 

 

6. JwtAuthenticationFilter

 

JwtAuthenticationFilter 필터를 구현하는데 사용한다.

 

모든 Request의 Authorization Header에서 JWT 인증 토큰을 읽는다.

토큰의 유효성을 검사한다.

해당 토큰을 가진 사용자 세부 사항을 로드한다.

Spring Security의 SecurityContext에서 사용자 세부 사항을 설정하고 이를 사용해 권한 검사를 수행한다. 그리고 SecurityContext 컨트롤러에 저장된 사용자 세부 정조에 접근하여 비즈니스 로직을 수행할 수 있다.

 

 

7. AuthenticationManagerBuilder / AuthentaicationManager

 

AuthenticationManagerBuilder는 AuthentaicationManager 사용자를 인증하기 위해 주요 Spring Security 인터페이스의 인스턴스를 생성하는데 사용된다.

 

메모리 내 인증, LDAP 인증, JDBC 인증을 작성하거나 사용자 인증 제공자를 추가하는데 사용된다.

 

우리 프로젝트에서는 customUserDetailsService와 passwordEncoder를 제공하여 AuthentaicationManager를 구축했다.

따라서 AuthentaicationManager 기반으로 로그인 API에서 사용자 인증을 진행할 것이다.

 

 

8. HttpSecurity 설정

 

HttpSecurty는 csrf, sessionManagement와 같은 보안 기능을 구성하는데 사용한다. 그리고 다양한 조건에 따른 자원 보호에 규칙을 추가한다.

 

우리 프로젝트에서는 모든 사용자에게 정적 리소스와 몇몇 공개 API에 대한 접근을 허용해주고, 오직 인증된 사용자에게만 다른 API 접근을 제한할 것이다.

 

우리는 또한, HttpSecurit 구성에서 JWTAuthenticationEntryPoint와 사용자 정의 JWTAuthenticationFilter를 추가할 것이다.

 

 

 

커스텀 Spring Security 클래스, 필터, 어노테이션 생성하기

 

여태까지 많은 사용자 정의 클래스와 필터로 Spring Security를 구성했다. 이제 이 클래스들을 하나씩 정의해보자.

앞으로 모든 사용자 정의 보안과 관련된 클래스는 com.example.polls.security 패키지에 저장할 것이다.



1. 커스텀 Spring Security AuthenticationEntryPoint

 

우리가 정의할 첫번째 보안 관련 클래스는 JwtAuthenticationEntryPoint다.

 

AuthenticationEntryPoint 인터페이스를 통해 이에 필요한 commence()를 구현한다.

 

인증이 필요한 자원에 접근하려고 시도하는 인증되지 않은 사용자에게 예외를 발생시킬 때마다 해당 메소드가 호출된다.

이럴때는 간단히 예외 메시지가 포함된 401 오류를 보여주도록 구현하자

 

com/example/polls/security/JwtAuthenticationEntryPoint.java

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
package com.example.polls.security;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
 
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
 
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
 
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationEntryPoint.class);
    @Override
    public void commence(HttpServletRequest httpServletRequest,
                         HttpServletResponse httpServletResponse,
                         AuthenticationException e) throws IOException, ServletException {
        logger.error("Responding with unauthorized error. Message - {}", e.getMessage());
        httpServletResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, e.getMessage());
    }
}
cs

 

 

2. 사용자 정의 Spring Security UserDetails

 

이번에는 UserDetails에 해당하는 UserPrincipal 클래스를 정의해보자.

 

이 인스턴스는 UserDetailsService를 통해 반환될 것이다. Spring Security는 UserPrincipal 객체에 저장된 정보를 사용해 인증과 권한 부여를 수행하게 된다.

 

com/example/polls/security/UserPrincipal.java

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
package com.example.polls.security;
 
import com.example.polls.model.User;
import com.fasterxml.jackson.annotation.JsonIgnore;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
 
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
 
public class UserPrincipal implements UserDetails {
    private Long id;
 
    private String name;
 
    private String username;
 
    @JsonIgnore
    private String email;
 
    @JsonIgnore
    private String password;
 
    private Collection<extends GrantedAuthority> authorities;
 
    public UserPrincipal(Long id, String name, String username, String email, String password, Collection<extends GrantedAuthority> authorities) {
        this.id = id;
        this.name = name;
        this.username = username;
        this.email = email;
        this.password = password;
        this.authorities = authorities;
    }
 
    public static UserPrincipal create(User user) {
        List<GrantedAuthority> authorities = user.getRoles().stream().map(role ->
                new SimpleGrantedAuthority(role.getName().name())
        ).collect(Collectors.toList());
 
        return new UserPrincipal(
                user.getId(),
                user.getName(),
                user.getUsername(),
                user.getEmail(),
                user.getPassword(),
                authorities
        );
    }
 
    public Long getId() {
        return id;
    }
 
    public String getName() {
        return name;
    }
 
    public String getEmail() {
        return email;
    }
 
    @Override
    public String getUsername() {
        return username;
    }
 
    @Override
    public String getPassword() {
        return password;
    }
 
    @Override
    public Collection<extends GrantedAuthority> getAuthorities() {
        return authorities;
    }
 
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }
 
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }
 
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }
 
    @Override
    public boolean isEnabled() {
        return true;
    }
 
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserPrincipal that = (UserPrincipal) o;
        return Objects.equals(id, that.id);
    }
 
    @Override
    public int hashCode() {
 
        return Objects.hash(id);
    }
}
cs

 

 

3. 사용자 정의 Spring Security UserDetailService

 

UserDetailsService 인터페이스로 사용자 이름이 주어지면, 해당 사용자의 데이터를 로드하는 CustomUserDetailsService 클래스를 정의하자

 

com/example/polls/security/CustomUserDetailsService.java

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
package com.example.polls.security;
 
import com.example.polls.model.User;
import com.example.polls.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
public class CustomUserDetailsService implements UserDetailsService {
 
    @Autowired
    UserRepository userRepository;
 
    @Override
    @Transactional
    public UserDetails loadUserByUsername(String usernameOrEmail)
            throws UsernameNotFoundException {
        // Let people login with either username or email
        User user = userRepository.findByUsernameOrEmail(usernameOrEmail, usernameOrEmail)
                .orElseThrow(() -> 
                        new UsernameNotFoundException("User not found with username or email : " + usernameOrEmail)
        );
 
        return UserPrincipal.create(user);
    }
 
    // This method is used by JWTAuthenticationFilter
    @Transactional
    public UserDetails loadUserById(Long id) {
        User user = userRepository.findById(id).orElseThrow(
            () -> new UsernameNotFoundException("User not found with id : " + id)
        );
 
        return UserPrincipal.create(user);
    }
}
cs

첫번째로 구현된 loadUserByUsername()은 Spring Security에 의해 사용된다. 해당 메소드 안에 구현되는 findByUsernameOrEmail는 사용자 이름 또는 이메일을 사용해 로그인 할 수 있도록 도와주는 기능을 담당한다.

 

두번째로 구현된 loadUserById()는 JWTAuthenticationFilter에 의해 사용될 것이다.

 



4. JWT 생성 및 검증을 위한 utility 클래스

 

다음 클래스는, 사용자가 성공적으로 로그인에 성공한 후, JWT를 생성하고 Request 시 Authorization Header에서 보낸 JWT의 유효성을 검사하는데 사용한다.

 

com/example/polls/security/JwtTokenProvider.java

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
package com.example.polls.security;
 
import io.jsonwebtoken.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.util.Date;
 
@Component
public class JwtTokenProvider {
 
    private static final Logger logger = LoggerFactory.getLogger(JwtTokenProvider.class);
 
    @Value("${app.jwtSecret}")
    private String jwtSecret;
 
    @Value("${app.jwtExpirationInMs}")
    private int jwtExpirationInMs;
 
    public String generateToken(Authentication authentication) {
 
        UserPrincipal userPrincipal = (UserPrincipal) authentication.getPrincipal();
 
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + jwtExpirationInMs);
 
        return Jwts.builder()
                .setSubject(Long.toString(userPrincipal.getId()))
                .setIssuedAt(new Date())
                .setExpiration(expiryDate)
                .signWith(SignatureAlgorithm.HS512, jwtSecret)
                .compact();
    }
 
    public Long getUserIdFromJWT(String token) {
        Claims claims = Jwts.parser()
                .setSigningKey(jwtSecret)
                .parseClaimsJws(token)
                .getBody();
 
        return Long.parseLong(claims.getSubject());
    }
 
    public boolean validateToken(String authToken) {
        try {
            Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(authToken);
            return true;
        } catch (SignatureException ex) {
            logger.error("Invalid JWT signature");
        } catch (MalformedJwtException ex) {
            logger.error("Invalid JWT token");
        } catch (ExpiredJwtException ex) {
            logger.error("Expired JWT token");
        } catch (UnsupportedJwtException ex) {
            logger.error("Unsupported JWT token");
        } catch (IllegalArgumentException ex) {
            logger.error("JWT claims string is empty.");
        }
        return false;
    }
}
cs

이 클래스는, properties로부터 JWT의 비밀 키와 만료 시간을 읽는다.

application.properties 파일에다가 jwtSecret과 jwtExpirationlnMs 속성을 추가하자

 

JWT 등록 정보

 

src/main/resources/application.properties

## App Properties
app.jwtSecret= JWTSuperSecretKey
app.jwtExpirationInMs = 604800000

 

 

5. 사용자 정의 Spring Security 인증 필터

 

JwtAuthenticationFilter에서 Request시 JWT 토큰을 가져와서 유효성을 검사하고, 토큰과 연관된 사용자를 로드하여 Spring Security에 전달한다.

 

com/example/polls/security/JwtAuthenticationFilter.java

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
package com.example.polls.security;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
 
public class JwtAuthenticationFilter extends OncePerRequestFilter {
 
    @Autowired
    private JwtTokenProvider tokenProvider;
 
    @Autowired
    private CustomUserDetailsService customUserDetailsService;
 
    private static final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        try {
            String jwt = getJwtFromRequest(request);
 
            if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) {
                Long userId = tokenProvider.getUserIdFromJWT(jwt);
 
                UserDetails userDetails = customUserDetailsService.loadUserById(userId);
                UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
 
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
        }
 
        filterChain.doFilter(request, response);
    }
 
    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7, bearerToken.length());
        }
        return null;
    }
}
cs

filter의 Authorization Header에서 가져온 JWT를 파싱하고, 사용자 ID를 얻는다.

 

그 이후 데이터베이스에서 사용자의 세부정보를 로드하고 Spring Security Context 내에서 인증을 설정한다.

 

JWT 클레임 내에서 사용자의 사용자 이름과 역할을 인코딩하고 UserDetails를 파싱하여 개체를 만들 수 있는데, 이를 통해 데이터베이스의 손상을 피할 수 있게 된다.

 



6. 현재 로그인 한 사용자에게 접근할 수 있는 사용자 정의 어노테이션

 

Spring Security는 컨트롤러에서 인증된 사용자에게 접근할 수 있는 @AuthenticationPrincipal 어노테이션을 제공한다.

CurrentUser 어노테이션은 @AuthenticationPrincipal 어노테이션으로 감싸져 있다.

 

com/example/polls/security/CurrentUser.java

1
2
3
4
5
6
7
8
9
10
11
12
package com.example.polls.security;
 
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import java.lang.annotation.*;
 
@Target({ElementType.PARAMETER, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {
 
}
cs

 

우리는 이 클래스를 통해 프로젝트에서 Spring Security와 관련된 어노테이션을 너무 많이 사용하지 않도록 구성했다. 의존성을 줄일 수 있고, Spring Security 제거 시 단순히 CurrentUser 어노테이션을 변경하는 방법으로 수행할 수 있게 된다.

이제 프로젝트에서 필요한 Spring Security의 구성이 완료되었다. 상당히 복잡하다.. 하지만 모두 중요한 로직이고 기본적인 것으로 프로젝트를 짜면서 이해하도록 노력하자!




로그인 및 회원가입 API 작성


API를 정의하기 전에, API에서 사용할 Request와 Response 페이로드를 정의하자

해당 클래스들은 com.example.polls.payload 패키지 안에 넣는다.


Request 페이로드

 

1.LoginRequest

 

com/example/polls/payload/LoginRequest.java

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
package com.example.polls.payload;
 
import javax.validation.constraints.NotBlank;
 
public class LoginRequest {
    @NotBlank
    private String usernameOrEmail;
 
    @NotBlank
    private String password;
 
    public String getUsernameOrEmail() {
        return usernameOrEmail;
    }
 
    public void setUsernameOrEmail(String usernameOrEmail) {
        this.usernameOrEmail = usernameOrEmail;
    }
 
    public String getPassword() {
        return password;
    }
 
    public void setPassword(String password) {
        this.password = password;
    }
}
cs

 

2.SignUpRequest

 

com/example/polls/payload/SignUpRequest.java

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
package com.example.polls.payload;
 
import javax.validation.constraints.*;
 
public class SignUpRequest {
    @NotBlank
    @Size(min = 4, max = 40)
    private String name;
 
    @NotBlank
    @Size(min = 3, max = 15)
    private String username;
 
    @NotBlank
    @Size(max = 40)
    @Email
    private String email;
 
    @NotBlank
    @Size(min = 6, max = 20)
    private String password;
 
    public String getName() {
        return name;
    }
 
    public void setName(String name) {
        this.name = name;
    }
 
    public String getUsername() {
        return username;
    }
 
    public void setUsername(String username) {
        this.username = username;
    }
 
    public String getEmail() {
        return email;
    }
 
    public void setEmail(String email) {
        this.email = email;
    }
 
    public String getPassword() {
        return password;
    }
 
    public void setPassword(String password) {
        this.password = password;
    }
}
cs

 

Response 페이로드

 

1.JwtAuthenticationResponse

 

com/example/polls/payload/JwtAuthenticationResponse.java

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
package com.example.polls.payload;
 
public class JwtAuthenticationResponse {
    private String accessToken;
    private String tokenType = "Bearer";
 
    public JwtAuthenticationResponse(String accessToken) {
        this.accessToken = accessToken;
    }
 
    public String getAccessToken() {
        return accessToken;
    }
 
    public void setAccessToken(String accessToken) {
        this.accessToken = accessToken;
    }
 
    public String getTokenType() {
        return tokenType;
    }
 
    public void setTokenType(String tokenType) {
        this.tokenType = tokenType;
    }
}
cs

 

2.ApiResponse

 

com/example/polls/payload/ApiResponse.java

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
package com.example.polls.payload;
 
public class ApiResponse {
    private Boolean success;
    private String message;
 
    public ApiResponse(Boolean success, String message) {
        this.success = success;
        this.message = message;
    }
 
    public Boolean getSuccess() {
        return success;
    }
 
    public void setSuccess(Boolean success) {
        this.success = success;
    }
 
    public String getMessage() {
        return message;
    }
 
    public void setMessage(String message) {
        this.message = message;
    }
}
cs

 

 

커스텀 비즈니스 Exceptions

 

equest가 유효하지 않거나, 예기치 않은 상황이 발생하면 API가 예외를 발생시켜야 한다.

이를 포함한 다른 예외사항에 대해서도 서로 다른 HTTP 상태 코드로 Response하려고 한다.

 

이러한 예외는 클래스에서 @ResponseStatus와 함께 정의된다.

(예외 클래스는 com.example.polls.exception 패키지에 넣도록 하자)

 

 

1.AppException

 

com/example/polls/exception/AppException.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.polls.exception;
 
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
 
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public class AppException extends RuntimeException {
    public AppException(String message) {
        super(message);
    }
 
    public AppException(String message, Throwable cause) {
        super(message, cause);
    }
}
 
cs

 

2.BadRequestException

 

com/example/polls/exception/BadRequestException.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.polls.exception;
 
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
 
@ResponseStatus(HttpStatus.BAD_REQUEST)
public class BadRequestException extends RuntimeException {
 
    public BadRequestException(String message) {
        super(message);
    }
 
    public BadRequestException(String message, Throwable cause) {
        super(message, cause);
    }
}
cs

 

 

 

3.ResourceNotFoundException

 

com/example/polls/exception/ResourceNotFoundException.java

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
package com.example.polls.exception;
 
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
 
@ResponseStatus(HttpStatus.NOT_FOUND)
public class ResourceNotFoundException extends RuntimeException {
    private String resourceName;
    private String fieldName;
    private Object fieldValue;
 
    public ResourceNotFoundException( String resourceName, String fieldName, Object fieldValue) {
        super(String.format("%s not found with %s : '%s'", resourceName, fieldName, fieldValue));
        this.resourceName = resourceName;
        this.fieldName = fieldName;
        this.fieldValue = fieldValue;
    }
 
    public String getResourceName() {
        return resourceName;
    }
 
    public String getFieldName() {
        return fieldName;
    }
 
    public Object getFieldValue() {
        return fieldValue;
    }
}
cs

 

 

 

에러 처리를 진행하는 클래스들 구현이 끝났다. 다음으로는 전체적인 인증을 관리하는 컨트롤러를 구현해보자


인증 컨트롤러

 

앞으로 구현할 AuthController에서는 로그인과 회원가입을 위한 API가 포함된 전체 코드가 구성된다.

 

컨트롤러에 해당하는 클래스는 모두 com.example.polls.controller 패키지에 넣자

 

 

com/example/polls/controller/AuthController.java

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
package com.example.polls.controller;
 
import com.example.polls.exception.AppException;
import com.example.polls.model.Role;
import com.example.polls.model.RoleName;
import com.example.polls.model.User;
import com.example.polls.payload.ApiResponse;
import com.example.polls.payload.JwtAuthenticationResponse;
import com.example.polls.payload.LoginRequest;
import com.example.polls.payload.SignUpRequest;
import com.example.polls.repository.RoleRepository;
import com.example.polls.repository.UserRepository;
import com.example.polls.security.JwtTokenProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
 
import javax.validation.Valid;
import java.net.URI;
import java.util.Collections;
 
@RestController
@RequestMapping("/api/auth")
public class AuthController {
 
    @Autowired
    AuthenticationManager authenticationManager;
 
    @Autowired
    UserRepository userRepository;
 
    @Autowired
    RoleRepository roleRepository;
 
    @Autowired
    PasswordEncoder passwordEncoder;
 
    @Autowired
    JwtTokenProvider tokenProvider;
 
    @PostMapping("/signin")
    public ResponseEntity<?> authenticateUser(@Valid @RequestBody LoginRequest loginRequest) {
 
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(
                        loginRequest.getUsernameOrEmail(),
                        loginRequest.getPassword()
                )
        );
 
        SecurityContextHolder.getContext().setAuthentication(authentication);
 
        String jwt = tokenProvider.generateToken(authentication);
        return ResponseEntity.ok(new JwtAuthenticationResponse(jwt));
    }
 
    @PostMapping("/signup")
    public ResponseEntity<?> registerUser(@Valid @RequestBody SignUpRequest signUpRequest) {
        if(userRepository.existsByUsername(signUpRequest.getUsername())) {
            return new ResponseEntity(new ApiResponse(false"Username is already taken!"),
                    HttpStatus.BAD_REQUEST);
        }
 
        if(userRepository.existsByEmail(signUpRequest.getEmail())) {
            return new ResponseEntity(new ApiResponse(false"Email Address already in use!"),
                    HttpStatus.BAD_REQUEST);
        }
 
        // Creating user's account
        User user = new User(signUpRequest.getName(), signUpRequest.getUsername(),
                signUpRequest.getEmail(), signUpRequest.getPassword());
 
        user.setPassword(passwordEncoder.encode(user.getPassword()));
 
        Role userRole = roleRepository.findByName(RoleName.ROLE_USER)
                .orElseThrow(() -> new AppException("User Role not set."));
 
        user.setRoles(Collections.singleton(userRole));
 
        User result = userRepository.save(user);
 
        URI location = ServletUriComponentsBuilder
                .fromCurrentContextPath().path("/api/users/{username}")
                .buildAndExpand(result.getUsername()).toUri();
 
        return ResponseEntity.created(location).body(new ApiResponse(true"User registered successfully"));
    }
}
cs

 

 



CORS 사용


클라이언트로부터 API에 접근이 가능하도록 개발 서버를 실행해야 한다.

 

cross origin requests를 config 패키지 안에서 WebMvcConfig 클래스를 생성해서 만들자.

 

com/example/polls/config/WebMvcConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.example.polls.config;
 
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
 
    private final long MAX_AGE_SECS = 3600;
 
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("*")
                .allowedMethods("HEAD""OPTIONS""GET""POST""PUT""PATCH""DELETE")
                .maxAge(MAX_AGE_SECS);
    }
}
cs

 

사용자 인증 과정을 처리하는 구현 과정이 상당히 긴 시간이었다..

 

지금까지 잘 따라왔으면 프로젝트 구조가 아래와 같이 돼야한다.

 

현재 프로젝트 구조 결과

 

이제 프로젝트의 루트 디렉토리에서 다시 서버를 실행해보자

 

mvnw spring-boot:run

 



로그인 / 회원가입 테스트

 

postman을 통해 로그인과 회원가입 API를 테스트해보자

 

회원가입

 

 

로그인

 

 

 

보호된 API 호출

 

로그인 API를 활용해 액세스 토큰을 얻은 후에 Authorization은 Request의 Header에 accessToken을 전달하여 보호된 API를 호출할 수 있다.

 

Authorization: Bearer <accessToken>

 

JwtAuthenticationFilter는 Header에서 accessToken을 읽은 후에 해당 API에 대한 접근을 허락/제한할 수 있다.

 

 

 

지금까지 JWT를 통한 로그인/회원가입으로 사용자 인증처리를 진행했다.

 

다음 파트에서는 설문 조사 작성, 설문 조사에 대한 투표, 사용자 프로필 검색 등을위한 나머지 API 작성을 해보자

반응형