목차
1. 개요
2. 개발환경
3. 전체 구조
4. 라이브러리 및 properties 설정
5. 코드 구현
6. 테스트
개요
사이드 프로젝트를 Rest API 로 설계하게 되어 로그인 방식을 고민하던 중
토큰 인증 방식을 알게 되었고, JWT 를 이용하여 프론트엔드와 백엔드 사이 인증을 구현해 보았습니다.
여기에서는 JWT에 관한 내용은 다루지 않고, 오직 구현에만 집중적으로 다룰 것입니다.
전체적인 흐름은 아래와 같습니다.
1. 회원가입
2. 로그인 -> 토큰 발행
3. 토큰을 이용하여 권한 확인
개발환경
언어 : JDK 17
프레임워크 : Spring Boot 3.1.3
빌드툴 : Gradle
인증/인가 : Spring Security 6.1.3
데이터베이스 : Postgresql & JPA
API 테스트 : Postman
라이브러리 및 properties 설정
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// jpa
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// validation
implementation 'org.springframework.boot:spring-boot-starter-validation'
// postgresql
implementation group: 'org.postgresql', name: 'postgresql', version: '42.4.3'
// security
implementation 'org.springframework.boot:spring-boot-starter-security'
// jwt
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.11.5'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.11.5'
properties.yml
spring:
datasource:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/postgres
username: postgres
password: 1234
sql:
init:
mode: always
h2:
console:
enabled: true
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
show_sql: true
defer-datasource-initialization: true
logging:
level:
me.sanghoon: DEBUG
jwt:
header: Authorization
secret: dfklWWHObhUWHEJKHhkjHWKXEHWUIEHXWUIWjhkjHJKWHEJWKeKEhjhKJHEWlhjkjklewkjkljkljewqnnjkbctjkwbtek
token-validity-in-seconds: 86400
data.sql
insert into authority(authority_name) values ('USER');
insert into authority(authority_name) values ('ADMIN');
전체 구조
소스 코드
JWT
TokenProvider
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.stream.Collectors;
@Component
public class TokenProvider implements InitializingBean {
private final Logger logger = (Logger) LoggerFactory.getLogger(TokenProvider.class);
private static final String AUTHORITIES_KEY = "auth";
private final String secret;
private final long tokenValidityInMilliseconds;
private Key key;
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.token-validity-in-seconds}") long tokenValidityInSeconds) {
this.secret = secret;
this.tokenValidityInMilliseconds = tokenValidityInSeconds * 1000;
}
@Override
public void afterPropertiesSet() {
byte[] keyBytes = Decoders.BASE64.decode(secret);
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 + this.tokenValidityInMilliseconds);
return Jwts.builder()
.setSubject(authentication.getName())
.claim(AUTHORITIES_KEY, authorities)
.signWith(key, SignatureAlgorithm.HS512)
.setExpiration(validity)
.compact();
}
public Authentication getAuthentication(String token) {
// 토큰을 이용해서 Claims 만듬
Claims claims = Jwts
.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
// Claims 에 들어있는 권한들을 가져옴
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 권한 정보를 이용해서 User 객체를 만듬
User principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, token, authorities);
}
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
logger.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
logger.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
logger.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
logger.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
}
JwtFilter
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.GenericFilterBean;
import java.io.IOException;
@RequiredArgsConstructor
public class JwtFilter extends GenericFilterBean {
private static final Logger logger = LoggerFactory.getLogger(JwtFilter.class);
public static final String AUTHORIZATION_HEADER = "Authorization";
private final 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) && tokenProvider.validateToken(jwt)) {
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
logger.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
logger.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
JwtAccessDeniedHandler
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
//필요한 권한이 없이 접근하려 할때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
JwtAuthenticationEntryPoint
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException) throws IOException {
// 유효한 자격증명을 제공하지 않고 접근하려 할때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
Controller
AuthController
import com.example.jwt.dto.LoginDto;
import com.example.jwt.dto.TokenDto;
import com.example.jwt.jwt.JwtFilter;
import com.example.jwt.jwt.TokenProvider;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
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;
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class AuthController {
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
@PostMapping("/authenticate")
public ResponseEntity<TokenDto> authorize(@RequestBody @Valid LoginDto loginDto) {
UsernamePasswordAuthenticationToken token =
new UsernamePasswordAuthenticationToken(loginDto.getEmail(), loginDto.getPassword());
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = tokenProvider.createToken(authentication);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + jwt);
return new ResponseEntity<>(new TokenDto(jwt), httpHeaders, HttpStatus.OK);
}
}
UserController
import com.example.jwt.dto.UserDto;
import com.example.jwt.entity.UserEntity;
import com.example.jwt.service.UserService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
@Slf4j
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/signup")
public ResponseEntity<UserEntity> signup(@RequestBody @Valid UserDto userDto) {
return new ResponseEntity<>(userService.signup(userDto), HttpStatus.OK);
}
@PostMapping("/user")
public ResponseEntity<String> getMyUserInfo() {
return ResponseEntity.ok("user");
}
@PostMapping("/user/{username}")
public ResponseEntity<String> getUserInfo(@PathVariable String username) {
return ResponseEntity.ok("admin");
}
}
Service
UserService
import com.example.jwt.dto.UserDto;
import com.example.jwt.entity.AuthorityEntity;
import com.example.jwt.entity.UserEntity;
import com.example.jwt.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Collections;
@Service
@RequiredArgsConstructor
public class UserService{
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public UserEntity signup(UserDto userDto) {
if(userRepository.findOneWithAuthoritiesByEmail(userDto.getEmail()).orElse(null) != null) {
throw new RuntimeException("이미 가입되어 있는 유저 입니다.");
}
AuthorityEntity authority = AuthorityEntity.builder()
.authorityName("USER")
.build();
UserEntity user = UserEntity.builder()
.email(userDto.getEmail())
.password(passwordEncoder.encode(userDto.getPassword()))
.username(userDto.getName())
.authorities(Collections.singleton(authority))
.createDate(LocalDateTime.now())
.build();
return userRepository.save(user);
}
}
CustomUserDetailsService
import com.example.jwt.entity.UserEntity;
import com.example.jwt.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
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.Component;
import java.util.List;
import java.util.stream.Collectors;
@Component
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity userEntity = userRepository.findOneWithAuthoritiesByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("유저정보가 없습니다."));
List<GrantedAuthority> grantedAuthorities = userEntity.getAuthorities().stream()
.map(authority -> new SimpleGrantedAuthority(authority.getAuthorityName()))
.collect(Collectors.toList());
return User.builder()
.username(userEntity.getEmail())
.password(userEntity.getPassword())
.authorities(grantedAuthorities)
.build();
}
}
Repository
UserRepository
import com.example.jwt.entity.UserEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findOneWithAuthoritiesByEmail(String email);
}
Entity
UserEntity
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Set;
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "users")
public class UserEntity{
@Id
@JsonIgnore
@Column(name = "id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "email", length = 50, unique = true)
private String email;
@Column(name = "username", length = 50)
private String username;
@JsonIgnore
@Column
private String password;
@Column
private LocalDateTime createDate;
@ManyToMany
@JoinTable(
name = "user_authority",
joinColumns = {@JoinColumn(name = "id", referencedColumnName = "id")},
inverseJoinColumns = {@JoinColumn(name = "authority_name", referencedColumnName = "authority_name")}
)
private Set<AuthorityEntity> authorities;
}
AuthorityEntity
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Entity
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "authority")
public class AuthorityEntity {
@Id
@Column(name = "authority_name", length = 50)
private String authorityName;
}
DTO
UserDto
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UserDto {
@NotBlank
private String email;
@NotBlank
private String password;
@NotBlank
private String name;
}
LoginDto
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginDto {
@NotBlank
private String email;
@NotBlank
private String password;
}
TokenDto
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class TokenDto {
private String jwt;
}
config
SecurityConfig
import com.example.jwt.jwt.JwtAccessDeniedHandler;
import com.example.jwt.jwt.JwtAuthenticationEntryPoint;
import com.example.jwt.jwt.JwtFilter;
import com.example.jwt.jwt.TokenProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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.configurers.AbstractHttpConfigurer;
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.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final TokenProvider tokenProvider;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.exceptionHandling(exception -> exception
.accessDeniedHandler(jwtAccessDeniedHandler)
.authenticationEntryPoint(jwtAuthenticationEntryPoint))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(request -> request
.requestMatchers("/api/signup").permitAll()
.requestMatchers("/api/authenticate").permitAll()
.requestMatchers("/api/user").hasAnyRole("USER", "ADMIN")
.requestMatchers("/api/user/*").hasAnyRole("ADMIN")
.anyRequest().authenticated())
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class)
;
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
테스트
로그인 하지 않은 상태로 요청시 401(자격없음) 에러가 발생한다.
회원 가입시 상태코드 200 과 유저 정보를 리턴 받는다.
회원 가입한 유저로 인증 요청시 JWT 발급 받는다.
발급 받은 토큰을 Authorization 에 담아서 user 에 요청하면 USER 권한이 있어서 user 를 리턴받는다.
반면에 ADMIN 권한이 필요한 /api/user/{username} API 에는 접근하지 못하고 403 (권한없음) 에러가 발생했다.
이렇게 간단한 테스트를 통해 JWT 인증 방식이 정상 작동하는 것을 볼 수 있었습니다.
위의 예시는 간단한 JWT 인증 방식이면 실제로는 좀더 탄탄하게 설계하고 토큰 탈취시 조치도 하셔야합니다.
'Spring Boot' 카테고리의 다른 글
[Spring Boot] Swagger 사용하기 (0) | 2024.05.05 |
---|---|
Error creating bean with name 'entityManagerFactory' defined in class path resource 에러 (0) | 2024.03.23 |
Spring Security + Spring Boot / Rest API Login 구현 (3) | 2023.11.20 |
Spring boot 로 구글 클라우드 저장소(GCS) 에 파일 업로드 하기 (0) | 2023.11.07 |
스프링 시큐리티 적용하기 (0) | 2023.11.05 |