개발/React + Spring Boot 게시판 만들기

Spring Security + JWT를 이용한 로그인 구현

RainyJune 2023. 5. 10. 16:15

게시판 웹페이지를 구현하려면 아무래도 로그인을 구현해야겠다는 생각이 들어 웹의 로그인 방법에 대해 알아봤다. 대표적으로 세가지 정도가 쓰인다.

  • Session
  • JWT
  • OAuth

Session

서버에 클라이언트의 정보를 기록해두는 방법이다.

클라이언트가 로그인 인증을 통과하면 클라이언트의 세션 정보를 기록해두고, 이후 클라이언트의 요청이 발생하면 클라이언트가 가지고 있는 세션정보(일반적으로 브라우저 쿠키에)를 대조하여 처리하게 된다.

 

JWT

클라이언트 사용자 정보를 JSON에 담은 token을 발급하는 방법이다.

클라이언트에서 서버로 요청을 보낼 때 발급받아놓은 token을 전달하고 서버는 전달받은 token의 유효성을 확인하여 통과하면 요청을 처리해준다.

 

OAuth (OpenID Authentication)

네이버 아이디로 로그인, 구글 아이디로 로그인 등 다른 웹사이트의 정보로 현재 이용하고자 하는 웹사이트의 접근권한을 제어하는 방법이며 개방형 표준이다.

 

위 세가지 중 JWT를 사용하기로 하고 Spring Boot를 사용하는만큼 Spring Security와 결합하여 JWT를 이용한 로그인 형태를 구현하기로 했다.


build.gradle

spring security, jwt에 대한 의존성을 추가해준다.

dependencies {
	implementation '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'
    ...
}

application.properties

JWT 비밀키를 적어둔다. 이 값은 임의의 문자열을 BASE64로 인코딩하여 기록한 것이다.

비밀키는 최소 512비트 이상의 값으로 설정하는 것을 권장하고 있으므로 약 64자 이상의 값을 설정하도록 하자.

https://leffept.tistory.com/450
jwt.secretkey=slkafjklejwfkjwoiejfiowejfiojweiofjklajsdefkljslkfja

사용자 정보 DB 구성

Spring Security에서는 사용자 정보 테이블, Role 테이블을 요구하고 있으며, 지난 포스트에 적은 TB_USER와 추가 테이블인 TB_ROLE, 사용자 정보와 ROLE의 관계를 정의할 TB_USER_ROLE 테이블로 구성한다.

이전에 TB_USER의 PASSWORD 길이를 20자로 정의했으나, spring security를 통해 해싱된 값이 해당 길이를 넘어서기 때문에 길이도 조정하도록 한다.


Entity 구성

사용자 정보를 담을 AuthMember, Role 정보를 담을 AuthRole entity를 정의한다.

각 Class명과 실제 mapping되는 table명이 다르므로 table명을 명시해주고, 각 property명과 table의 column name이 다른경우도 column name을 명시해준다. AuthMember의 roles property는 tb_user_role에 outer join된 값을 가져오는 값이므로 @ManyToMany, @JoinTable annotation을 추가해준다.

@Builder
@Entity
@Table(name="tb_user")
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class AuthMember {
    @Id
    @Column(name="user_id")
    private String userId;

    @Column(nullable = false)
    private String password;

    @Column(name="user_name", nullable = false)
    private String userName;

    @Column(name="user_level", nullable = false)
    private String userLevel;

    @ManyToMany
    @JoinTable(
            name = "tb_user_role",
            joinColumns={@JoinColumn(name="user_id", referencedColumnName = "user_id")},
            inverseJoinColumns={@JoinColumn(name="role_id", referencedColumnName = "role_id")})
    private List<AuthRole> roles;

    @Column(name="create_user", nullable = false)
    private String createUser;

    @Column(name="create_date", nullable = false)
    private Date createDate;
}
@Entity
@Table(name="tb_role")
@Getter
@NoArgsConstructor
public class AuthRole {
    @Id
    @Column(name="role_id")
    private String roleId;

    @Builder
    public AuthRole(String roleId) {
        this.roleId = roleId;
    }
}

JWT 설정 관련 구성

JwtConfig

application.properties에 정의한 jwt 관련 설정값을 TokenProvider에 주입하고 Bean을 생성하는 역할을 수행한다.

@Configuration
public class JwtConfig {
    @Value("${jwt.secretkey}")
    private String secretKey;

    @Bean(name="tokenProvider")
    public TokenProvider tokenProvider() {
        return new TokenProvider(secretKey);
    }
}

TokenProvider

토큰을 생성 및 검증하는 역할을 수행하며 Authentication 객체를 생성한다.

public class TokenProvider {
    protected final Logger logger = LoggerFactory.getLogger(TokenProvider.class);
    protected static final String AUTHORITIES_KEY = "auth";
    protected final String secretKey;
    protected Key key;

    public TokenProvider(String secretKey) {
        this.secretKey = secretKey;

        byte[] keyBytes = Decoders.BASE64.decode(secretKey);
        this.key = Keys.hmacShaKeyFor(keyBytes);
    }

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

        long now = (new Date()).getTime();
        Date validity = new Date(now + 1000 * 60 * 10); // 10분

        return Jwts.builder()
                .setSubject(authentication.getName())
                .claim(AUTHORITIES_KEY, authorities)
                .signWith(key, SignatureAlgorithm.HS256)
                .setExpiration(validity)
                .compact();
    }

    public Authentication getAuthentication(String token) {
        Claims claims = Jwts
                .parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token)
                .getBody();

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

        User principal = new User(claims.getSubject(), "", authorities);

        return new UsernamePasswordAuthenticationToken(principal, token, authorities);
    }

    public boolean validateToken(String token) {
        try {
            Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
            return true;
        } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
            logger.info("Invalid JWT signing.");
        } catch (ExpiredJwtException e) {
            logger.info("Token was expired");
        } catch (UnsupportedJwtException e) {
            logger.info("Not supported JWT token");
        } catch (IllegalArgumentException e) {
            logger.info("Wrong JWT token");
        }
        return false;
    }
}

JwtFilter

doFilter method는 토큰의 유효성을 검사하고, 토큰에서 username, authorities를 추출하여 Security Context에 Authentication 객체로 저장한다.

토큰 검증시 추출한 username에 대해 DB에 실제 존재하는 사용자인지 검증하지 않기때문에 필요하다면 별도로 구현해야한다.

Spring Security의 Filter들은 filterChain에 등록되어 다음 filter를 계속해서 호출하도록 디자인 되어 있다.

그래서 doFilter method 구현시 반드시 마지막에 filterChain.doFilter를 호출해야 한다.

https://codevang.tistory.com/275
public class JwtFilter extends GenericFilterBean {
    private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);

    public static final String AUTHORIZATION_HEADER = "Authorization";

    private TokenProvider tokenProvider;

    public JwtFilter(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
        throws IOException, ServletException
    {
        HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
        String jwt = resolveToken(httpServletRequest);
        String requestURI = httpServletRequest.getRequestURI();

        if (StringUtils.hasText(jwt) == true
            && tokenProvider.validateToken(jwt) == true) {
            Authentication authentication = tokenProvider.getAuthentication(jwt);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            logger.debug("'{}' authentication is saved on Security Context, URI: {}", authentication.getName(), requestURI);
        } else {
            logger.debug("There is no valid JWT token, URI: {}", requestURI);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }

    // Get token information from header
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
        if (StringUtils.hasText(bearerToken) == true
            && bearerToken.startsWith("Bearer ") == true) {
            return bearerToken.substring(7);
        }

        return null;
    }
}

Security Configuration

CorsFilterConfig

HTTP request는 Cross-Site HTTP Request가 가능하다. 하지만 Same Origin Policy를 적용받는데, protocol, hostname, port가 동일한 origin의 요청만 처리가 가능하다.

React와 같은 SPA(Single Page Application)의 경우 일반적으로 front-end와 back-end의 도메인이 다르기 때문에 CORS허용 정책이 필요하다.

@Configuration
public class CorsFilterConfig {
    @Bean
    public CorsFilter corsFilter() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowCredentials(true);
        config.addAllowedOrigin("*"); // 허용할 URL
        config.addAllowedHeader("*"); // 허용할 Header
        config.addAllowedMethod("*"); // 허용할 Http Method

        source.registerCorsConfiguration("/api/**", config);
        return new CorsFilter(source);
    }
}

AuthenticationEntryPoint

Spring Security에서 인증을 처리중에 유효한 자격증명이 아니라고 판단되는 경우 AuthenticationEntryPoint의 commence method를 통해 인증 실패 관련 처리를 하게된다. 이 부분을 customize한다.

@Component
public class JwtAuthEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException {
        // return 401 error.
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    }
}

AccessDeniedHandler

Spring Security에서 request처리시 해당 request에 대해 사용자의 권한이 적합하지 않은 경우 AccessDeniedHandler의 handle method를 통해 관련 처리를 수행한다. 이 부분을 customize 하면 아래와 같다.

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        // return 403 error
        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    }
}

Jwt Cutom Security Config

SecurityConfigurerAdapter를 상속받아 구현한 클래스로 Spring Security의 UsernamePasswordAuthenticationFilter 수행 전에 TokenProvider를 주입한 JwtFilter를 수행하도록 한다.

@AllArgsConstructor
public class JwtSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    @Autowired
    private TokenProvider tokenProvider;

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

SecurityConfig

Web Security를 설정하는 class로 @EnableWebSecurity annotation을 추가하여 기본적인 웹 보안을 활성화한다.

@Configuration
@AllArgsConstructor
@EnableWebSecurity
public class SecurityConfig {
    @Autowired
    private TokenProvider tokenProvider;

    @Autowired
    private CorsFilter corsFilter;

    @Autowired
    private JwtAuthEntryPoint entryPoint;

    @Autowired
    private JwtAccessDeniedHandler accessDeniedHandler;

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

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf().disable() // Cross site request forgery. rest api에서는 권한이 필요한 요청을 위해 인증 정보를 포함시켜야 하고 서버에 인증 정보를 저장하지 않기 때문에 필요 없다.
                // usernamepasswordauthenticationfilter 전에 corsFilter 먼저 실행하겠음
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling()
                .authenticationEntryPoint(entryPoint)
                .accessDeniedHandler(accessDeniedHandler)
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // jwt를 사용하기 때문에 세션도 사용하지 않음
                .and()
                .formLogin().disable() // form based authentication 사용하지 않음
                .httpBasic().disable() // http basic authentication 사용하지 않음
                .authorizeRequests()
                .requestMatchers("/api/auth/*").permitAll() // /api/auth 하위 경로에 대한 요청에 관해 모두 접근가능하게 함
                .anyRequest().authenticated()
                .and()
                .apply(new JwtSecurityConfig(tokenProvider));
        return http.build();
    }
}

UserAdapter

로그인 api 호출시 AuthMember entity를 그대로 사용하기 위한 User를 상속받은 adapter class

public class UserAdapter extends User {
    private AuthMember authMember;

    public UserAdapter(AuthMember authMember) {
        super(authMember.getUserId(), authMember.getPassword(), authorities(authMember.getRoles()));
        this.authMember = authMember;
    }

    public AuthMember getAuthMember() {
        return this.authMember;
    }

    private static List<GrantedAuthority> authorities(List<AuthRole> roles) {
        return roles.stream()
                .map(authority -> new SimpleGrantedAuthority(authority.getRoleId()))
                .collect(Collectors.toList());
    }
}

CustomUserDetailsService

인증 API 호출시 loadUserByUsername을 재정의하기 위한 service class

UserDetails class를 반환해야하지만 UserAdapter가 User class를 상속받았기 때문에 UserAdapter가 역할을 대신한다.

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
    @Autowired
    private AuthMemberRepository authMemberRepository;

    @Override
    @Transactional
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AuthMember authMember = authMemberRepository.findByUserId(username)
                .orElseThrow(() -> new UsernameNotFoundException("Can't find user ID [" + username + "]"));

        return new UserAdapter(authMember);
    }
}

Access Token 인증 API (Login)

LoginRequestDto, LoginResponseDto 구성

Login시 사용자 ID와 비밀번호를 받아 인증을 처리하고 Access Token을 반환하기 때문에 아래와 같이 구성한다.

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginRequestDto {
    private String userId;
    private String password;
}
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponseDto {
    private String accessToken;
}

AuthMemberRepository, AuthRoleRepository

인증시 사용할 Entity들에 대한 Repository를 구성한다.

사용자의 role을 tb_user_role을 outer join하여 조회할 것이기 때문에 @EntityGraph annotation을 추가한다.

public interface AuthMemberRepository extends JpaRepository<AuthMember, String> {
        @EntityGraph(attributePaths = "roles")
        Optional<AuthMember> findByUserId(String userId);
}
public interface AuthRoleRepository extends JpaRepository<AuthRole, String> {
}

AuthService

인증 처리를 위한 Service를 구현한다.

사용자 ID와 password로 UsernamePasswordAuthenticationToken 객체를 생성하고 이를 이용하여 authentication 객체를 생성한다. 이 때 CustomUserDetailsService에 재정의한 loadUserByUsername이 호출된다.

@Service
@AllArgsConstructor
public class AuthService {
    @Autowired
    private TokenProvider tokenProvider;
    @Autowired
    private AuthenticationManagerBuilder authenticationManagerBuilder;

    public LoginResponseDto authenticate(String userId, String password) {
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userId, password);

        Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);
        String accessToken = tokenProvider.createToken(authentication);

        return LoginResponseDto.builder().accessToken(accessToken).build();
    }
}

AuthController

API 호출부를 구현한다.

login method는 AuthService의 authenticate method를 통해 access token을 발급하고 해당 token을 Http Header에 담아 Response dto와 함께 반환한다.

@RestController
@RequestMapping("/api/auth")
@AllArgsConstructor
public class AuthController {
    @Autowired
    private AuthService authService;

    @PostMapping("/login")
    public ResponseEntity<LoginResponseDto> login(@RequestBody LoginRequestDto loginRequestDto) {
        LoginResponseDto token = authService.authenticate(loginRequestDto.getUserId(), loginRequestDto.getPassword());

        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + token.getAccessToken());

        return new ResponseEntity<>(token, httpHeaders, HttpStatus.OK);
    }
}

사용자 등록 API

RegisterUserRequestDto, RegisterUserResponseDto

사용자로부터 ID, password, 사용자 이름을 입력받고 userLevel, createUser, createDate에 대해서는 front측에서 값을 넘겨받도록 구성하려고 했으나 굳이 front측에서 넘겨받을 필요가 없을 것 같기도 하고 추후 제거할 수 있음.

@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RegisterUserRequestDto {
    private String userId;

    private String password;

    private String userName;

    private String userLevel;

    private String createUser;

    private Date createDate;
}
@Builder
@Data
@NoArgsConstructor
@AllArgsConstructor
public class RegisterUserResponseDto {
    private String userId;
    private String userName;
    private List<String> roles;

    public static RegisterUserResponseDto of (AuthMember authMember) {
        if (authMember == null) return null;

        return RegisterUserResponseDto.builder()
                .userId(authMember.getUserId())
                .userName(authMember.getUserName())
                .roles(authMember.getRoles().stream()
                        .map(authority -> authority.getRoleId())
                        .collect(Collectors.toList()))
                .build();
    }
}

RegisterUserService

signUp method를 통해 사용자 등록을 수행한다.

userId가 pk이므로 중복검사를 하고 중복인 경우 DuplicateUserException을 throw한다.

role은 tb_role에 현재 role_id='admin' record만 있어서 임의로 넣어놓은 것이다.

password는 spring security의 기본인 BCryptPasswordEncoder를 통해 해싱하여 저장한다.

BCryptPasswordEncoder의 해싱값은 매번 변경되는데 값 일치 검사 등 필요시 spring security의 library를 통해 처리해야 한다.

@Service
@AllArgsConstructor
public class RegisterUserService {
    @Autowired
    private AuthMemberRepository authMemberRepository;
    
    @Autowired
    private PasswordEncoder passwordEncoder;

    @Transactional
    public RegisterUserResponseDto signUp(RegisterUserRequestDto registerUserRequestDto) {
        if (authMemberRepository.findByUserId(registerUserRequestDto.getUserId()).orElseGet(()->null) != null) {
            throw new DuplicateUserException("ID [" + registerUserRequestDto.getUserId() + "] already exists in system");
        }

        AuthRole role = AuthRole.builder().roleId("admin").build();
        List<AuthRole> authorities = new ArrayList<>();
        authorities.add(role);

        AuthMember member = AuthMember.builder()
                .userId(registerUserRequestDto.getUserId())
                .password(passwordEncoder.encode(registerUserRequestDto.getPassword()))
                .userName(registerUserRequestDto.getUserName())
                .userLevel(registerUserRequestDto.getUserLevel())
                .roles(authorities)
                .createUser(registerUserRequestDto.getCreateUser())
                .createDate(new Date())
                .build();

        return RegisterUserResponseDto.of(authMemberRepository.save(member));
    }
}

RegisterUserController

API의 중간 주소를 AuthController와 동일하게 /api/auth로 사용했는데, 이렇게 별개의 class에서 동일한 중간 주소를 사용하는 것이 올바른 방법은 아닌 것 같다. 별개의 주소를 사용하는게 나은 것 같다.

@RestController
@RequestMapping("/api/auth")
@AllArgsConstructor
public class RegisterUserController {
    @Autowired
    private final RegisterUserService service;

    @PostMapping("/signUp")
    public ResponseEntity<RegisterUserResponseDto> signUp(@RequestBody RegisterUserRequestDto dto) {
        RegisterUserResponseDto userInfo = service.signUp(dto);

        return ResponseEntity.ok(userInfo);
    }
}

사용자 등록 테스트

테스트는 front의 back-end 연결이 아직 수행되지 않았으므로 Talend API Tester를 통해 수행했다.

정상적으로 수행되어 RegisterUserResponseDto의 내용이 온전하게 return된 것을 알 수 있다.

 

DB의 TB_USER를 조회하면

정상적으로 저장된 것을 확인할 수 있다.(password는 가림)

 

동일한 요청을 한번 더 보내면

이렇게 401 메세지가 리턴되는데, 서버의 콘솔창을 확인하면 

...security.exception.DuplicateUserException: ID [test] already exists in system

이렇게 DuplicateUserException이 throw 된 것을 볼 수 있다.


로그인(인증) 테스트

위에서 생성한 사용자의 ID와 비밀번호로 로그인 api를 호출하면

Header의 Authorization에 access token정보가 담겨있고, login response dto의 accessToken 내용도 잘 담겨서 return된 것을 확인할 수 있다.


참고 자료

https://velog.io/@suhongkim98/Spring-Security-JWT%EB%A1%9C-%EC%9D%B8%EC%A6%9D-%EC%9D%B8%EA%B0%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
https://sas-study.tistory.com/362
https://blog.naver.com/donghwy98/222649327475
https://toycoms.tistory.com/37
https://leffept.tistory.com/450

'개발 > React + Spring Boot 게시판 만들기' 카테고리의 다른 글

Back-end 개발 시작 DB 접속  (0) 2023.05.02
DB 구성하기  (0) 2023.05.02
Back-end 구성하기  (0) 2023.05.02
게시판 Project 시작  (0) 2023.05.02