Form 로그인글에서도 설명했다 싶이 일단 form 로그인과 토큰 방식의 로그인을 어떤 걸 채택할지 몰라 일단 둘다 강의와 각종 정리한 걸 보며 제가 정리를 한번 해보았습니다.
기본적으로 코드에 주석을 많이 달아놓았기에 깊은 설명을 안하는 것으로 하겠습니다.
Jwt의 설명은 망나니 개발자님이 정리를 굉장히 잘 해주셔서 이걸 보고 오시면 좋을 것 같습니다.
[Server] JWT(Json Web Token)란? - MangKyu's Diary (tistory.com)
[Server] JWT(Json Web Token)란?
현대 웹서비스에서는 토큰을 사용하여 사용자들의 인증 작업을 처리하는 것이 가장 좋은 방법이다. 이번에는 토큰 기반의 인증 시스템에서 주로 사용하는 JWT(Json Web Token)에 대해 알아보도록 하
mangkyu.tistory.com
토큰 방식의 장단점
장점
- 클라이언트 측에서 토큰에 인증 정보를 저장하고 관리하기 때문에 중앙의 인증서버,데이터 스토어에 대한 의존성이 없다. 즉 시스템 수평 확장에 유리하다.
- 모바일 같은 경우 쿠키를 사용하는데 어려움이 있기에 토큰 방식을 주로 사용한다.
단점
-
- Payload 자체는 암호화되지 않기 때문에 중요한 정보를 담을 수 없다.
- 토큰을 탈취당하면 대처하기가 어렵다.
- 토큰의 길이가 길어지고 , 인증 요청이 많아질수록 네트워크 부하가 심해질 수 있다.
토큰 방식 로그인
1.클라이언트에서 요청을 한다.
2. 서버에서 DB에 해당 ID/PW를 가진 User가 있다면, Access Token과 Refresh Token을 발급해준다.
3. 클라이언트는 발급받은 Access Token을 헤더에 담아서 서버가 허용한 API를 사용할 수 있게 된다.
여기서 Refresh Token은 새로운 Access Token을 발급하기 위한 토큰이다. 기본적으로 Access Token은 외부 유출 문제로 인해 유효기간을 짧게 설정하는데, 정상적인 클라이언트는 유효기간이 끝난 Access Token에 대해 Refresh Token을 사용하여 새로운 Access Token을 발급받을 수 있다. 따라서, Refresh Token의 유효기간은 Access Token의 유효기간보다 길게 설정한다.
국제 인터넷 표준화 기구(IETF)에서는 이를 방지하기 위해 Refresh Token도 Access Token과 같은 유효 기간을 가지도록 하여, 사용자가 한 번 Refresh Token으로 Access Token을 발급 받았으면, Refresh Token도 다시 발급 받도록 하는 것을 권장하고 있습니다.
이 방식은 Refresh Token 존재 이유 자체를 없애는 것 같기에 토큰 2개를 만들이유가 없기에 생각하기에 나는 그냥 평균적으로 사용하는 Refresh Token 의 기간을 더 길게 잡는 것으로 설정을 했습니다.
JWT 라이브러리
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
스프링부트 프로젝트 생성(web, security, h2, validation, lombok, jpa) 후 따로 jwt에 대한 라이브러리를 등록해준다
DTO
TokenDto 생성
import lombok.*;
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class TokenDto {
private String grantType;
private String accessToken;
private String refreshToken;
}
Bearer 인증 방식
나는 Bearer 인증방식을 사용했는데 이 방식은 OAuth 2.0 프레임워크에서 사용하는 토큰 인증 방식입니다. “Bearer”은 소유자라는 뜻인데, “이 토큰의 소유자에게 권한을 부여해줘”라는 의미로 이름을 붙였다고 합니다. Bearer 와 토큰을 인증 헤더에 입력을 한다. 더 자세한 내용은 RFC 6750에 정의되어 있으니 찾아보면 좋을 것 같습니다.
예시: Authorization : Bearer (accessToken)
Basic 인증 방식
Basic 인증 방식은 말 그대로 가장 기본적인 인증 방식입니다. 인증 정보로 사용자 ID, 비밀번호를 사용을 합니다. base64로 인코딩한 “사용자ID:비밀번호” 문자열을 Basic과 함께 인증 헤더에 입력을 합니다. 더 자세한 내용은 RFC 7617에 정의되어 있으니 찾아보면 좋을 것 같습니다.
Base64는 쉽게 복호화할 수 있기 때문에 단순 base64 인코딩된 사용자 ID와 비밀번호를 HTTP로 전달하면 요청의 보안이 보장되지 않습니다. Basic 인증을 사용하는 요청은 꼭 HTTPS, SSL/TLS로 통신해야 안전합니다.
User
@Entity
@Table(name = "`user`")
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class User implements UserDetails {
@Id
@Column(name = "user_id")
@GeneratedValue(strategy = GenerationType.AUTO)
private Long userId;
@Column(name = "username", length = 50, unique = true)
private String username;
@Column(name = "password", length = 100)
private String password;
@Column(name = "nickname", length = 50)
private String nickname;
@Column(name = "activated")
private boolean activated;
@ElementCollection(fetch = FetchType.EAGER)
@Builder.Default
private List<String> roles = new ArrayList<>();
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return this.roles.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
return false;
}
}
LoginDto
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class LoginDto {
@NotNull
@Size(min = 3, max = 50)
private String username;
@NotNull
@Size(min = 3, max = 100)
private String password;
}
application.yml
spring:
security:
user:
name: user
password: 1234
h2:
console:
enabled: true
datasource:
driver-class-name: org.h2.Driver
url: jdbc:h2:tcp://localhost/~/test
username: kjg
password:
jpa:
database-platform: org.hibernate.dialect.H2Dialect
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
show_sql: true
defer-datasource-initialization: true
jwt:
header: Authorization
#HS512 알고리즘을 사용할 것이기 때문에 512bit, 즉 64byte 이상의 secret key를 사용해야 한다.
# 'silvernine-tech-spring-boot-jwt-tutorial-secret-silvernine-tech-spring-boot-jwt-tutorial-secret' 라는 문자열을 base64 인코딩 한 값
secret: c2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQtc2lsdmVybmluZS10ZWNoLXNwcmluZy1ib290LWp3dC10dXRvcmlhbC1zZWNyZXQK
accessTokenTimes: 86400
refreshTokenTimes: 172800 #회사의 규정으로 정하면 되고 나는 일단은 2배로 기준을 잡았다
jwt.secret
secret key 값 같은 경우 긴 문자열을 암호화를 해서 이 값으로 key값을 사용을 많이 합니다.
accessTokenTimes , refreshTotkenTimes
두 토큰의 기간 설정 같은 경우 규정으로 정해진 시간으로 설정을 하면되고
refreshTotken 기간여부에 따라 accessToken 토큰을 발급하기에 더 길게 설정을 해두면 됩니다.
TokenProvider
@Slf4j
@Component
public class TokenProvider implements InitializingBean {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER= "Bearer";
private final String secret;
private final long accessTokenTimes;
private final long refreshTokenTimes;
private Key key;
// InitializingBean 생성후 의존성 주입 받은 후 secret 값을 Decoding 해서 key 변수에 할당
public TokenProvider(
@Value("${jwt.secret}") String secret,
@Value("${jwt.accessTokenTimes}") long accessTokenTimes,
@Value("${jwt.refreshTokenTimes}") long refreshTokenTimes) {
this.secret = secret;
this.accessTokenTimes = accessTokenTimes * 1000;
this.refreshTokenTimes = refreshTokenTimes * 1000;
}
@Override // 의존관계 주입이 끝난 후에 실행하는 InitializingBean 의 메소드
public void afterPropertiesSet() throws Exception {
byte[] keyBytes = Decoders.BASE64.decode(secret);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
//토큰 생성 메소드
public TokenDto createToken(Authentication authentication) {
//받은 파라미터중 권한들만 List로 추출
List<String> authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
//yml 파일에서 설정한 만료시간으로 현재시간과 계산해서 토큰 만료시간을 생성
long now = (new Date()).getTime();
Date expireAccessTokenTimes = new Date(now + this.accessTokenTimes);
Date expireRefreshTokenTimes = new Date(now + this.refreshTokenTimes);
String accessToken = TokenCreate(authentication, authorities, expireAccessTokenTimes);
String refreshToken = TokenCreate(authentication, authorities, expireRefreshTokenTimes);
return TokenDto.builder()
.grantType(BEARER)
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
private String TokenCreate(Authentication authentication, List<String> authorities, Date validity) {
return Jwts.builder()
.setSubject(authentication.getName()) //사용자의 아이디를 입력
.claim(AUTHORITIES_KEY, authorities) // 클레임 등록
.signWith(key, SignatureAlgorithm.HS512) // key 값 과 암호 알고리즘을 설정
.setExpiration(validity) //토큰 만료 시간을 설정
.compact();
}
// 토큰에 담겨있는 Authentication 객체 반환 메소드
public Authentication getAuthentication(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key) //내가 설정한 key 값으로 복호화
.build()
.parseClaimsJws(token)
.getBody();
Collection<? extends GrantedAuthority> authorityList = Arrays.stream // claims 에서 권한정보를 리스트로 추출
(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
User principal = new User(claims.getSubject(), "", authorityList);
return new UsernamePasswordAuthenticationToken(principal,token,authorityList);
}
// 토큰 정보를 검증하는 메서드
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;
}
}
createToken() : 두개의 토큰을 만들고 반환해주는 메소드입니다.
getAuthentication() :는 token을 String 값으로 받아와 (user 정보가 담겨있는) Authentication 객체를 반환하는 메소드입니다.
Collection<? extends GrantedAuthority> 방식으로 변수 값 을 받는 이유는 확장성을 위해 사용했습니다.
(구현체를 갈아끼기에 굉장히 편리해집니다 )
JwtFilter
@Slf4j
public class JwtFilter extends GenericFilterBean {
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); // request 에서 token 값을 가져오는 함수
String requestURI = httpServletRequest.getRequestURI();
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)) { //토큰이 null값이나 비어있지않고 적절한 토큰이 맞을경우
Authentication authentication = tokenProvider.getAuthentication(jwt); // 토큰에 담겨있는 Authentication 객체를 추출
SecurityContextHolder.getContext().setAuthentication(authentication); // SecurityContext 에 저장
log.debug("Security Context에 '{}' 인증 정보를 저장했습니다, uri: {}", authentication.getName(), requestURI);
} else {
log.debug("유효한 JWT 토큰이 없습니다, uri: {}", requestURI);
}
filterChain.doFilter(servletRequest, servletResponse);
}
//request Header에서 토큰의 정보를가져오는 메소드
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
토큰이 올바른 토큰인지 검증을 한 후 토큰에 담겨있는 Authentication 객체를 추출한다.
그후 SecurityContext에 인증 정보를 저장을 한 후 메소드가 끝난다.
SecurityConfig
@EnableWebSecurity // 모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true) //@PreAuthorize 어노테이션을 메소드단위로 추가하기위해 적용
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
// token을 사용하는 방식이기 때문에 csrf를 disable합니다.
.csrf().disable()
.exceptionHandling()//에러핸들링 부분 만든에러핸들링클래스 주입
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
.and()
.headers()
.frameOptions()
.sameOrigin()// h2 console 설정
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) //세션을 사용하지않기에 STATELESS 적용
.and()
.authorizeRequests()
.antMatchers("/api/hello").permitAll()
.antMatchers("/api/login").permitAll()
.antMatchers("/api/signup").permitAll()
.anyRequest().authenticated()
.and()
//UsernamePasswordAuthenticationFilter 전에 내가만든 필터를 적용
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class) .and()
.build();
}
@Bean
public WebSecurityCustomizer websecurityCustomizer() {
return (web) -> web.ignoring()
.antMatchers(
"/h2-console/**"
, "/favicon.ico");
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
webSecurityCustomizer 메소드같은경우는 정적리소스같은 경우는 제외를 해주는 메소드입니다
Spring Security 같은 경우 암호화를 해서 데이터베이스 password를 넣어주어야 하기 때문에
passwordEncoder 빈을 등록 해 줍니다.
Repository
public interface UserRepository extends JpaRepository<User, Long> {
@EntityGraph(attributePaths = "authorities") //지연 조회가 아니라 권한 동시 조회
Optional<User> findOneWithAuthoritiesByUsername(String username);
}
username으로 권한과 uesr의 정보를 가져오는 메소드입니다.
Service
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenProvider tokenProvider;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
public TokenDto login(LoginDto loginDto){
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword()); //UsernamePasswordAuthenticationToken 토큰 생성
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);// authenticationManagerBuilder.getObject().authenticate
SecurityContextHolder.getContext().setAuthentication(authentication); // 인증 객체를 SecurityContext 저장
TokenDto token = tokenProvider.createToken(authentication);//토큰생성
return token;
}
@Transactional
// USER 권한으로 회원가입을 하는 메소드
public UserDto signup(UserDto userDto) throws DuplicateMemberException {
Optional<User> user = userRepository.findOneWithAuthoritiesByUsername(userDto.getUsername());
if (user.isPresent()) {
throw new DuplicateMemberException("이미 가입되어 있는 유저입니다.");
}
User buildUser = User.builder()
.username(userDto.getUsername())
.password(passwordEncoder.encode(userDto.getPassword()))
.nickname(userDto.getNickname())
.roles(List.of("USER"))
.build();
return UserDto.from(userRepository.save(buildUser));
}
@Transactional(readOnly = true)
public UserDto getUserWithAuthorities(String username) { // username 으로 데이터를 조회후 가져온다.
return UserDto.from(userRepository.findOneWithAuthoritiesByUsername(username)
.orElse(null));
}
@Transactional(readOnly = true) // Security Context 안에 있는 유저객체를 가져오고 db 에서 값을 조회후 가져온다
public UserDto getMyUserWithAuthorities() {
return UserDto.from(
SecurityUtil.getCurrentUsername()
.flatMap(userRepository::findOneWithAuthoritiesByUsername)
.orElseThrow(() -> new NullPointerException("User is Null"))
);
}
}
Login
처음에 이코드를 보시면 어디서 유저의 정보를 검증을 하는지 이해가 안가실 겁니다.
authenticationManagerBuilder.getObject().authenticate(authenticationToken) 이 부분이 핵심입니다
이 메소드를 실행시키면 안에 로직의 순서는 아래와 같습니다.
- AuthenticationManager 호출되어 구현체인 ProviderManager 의 authenticate() 메소드가 실행됩니다
- 해당 메소드에선 AuthenticaionProvider 인터페이스의 authenticate() 메소드를 실행하는데 해당 인터페이스에서 데이터베이스에 있는 이용자의 정보를 가져오는 UserDetailsService 인터페이스를 사용합니다.
- 그래서 UserDetailsService 인터페이스의 loadUserByUsername() 메소드를 호출하게 됩니다.
- 따라서 CustomUserDetailsService 구현체에 오버라이드된 loadUserByUsername() 메소드를 호출하게 되는 것입니다.
Spring Security 의 아키텍처를 이해하고 계신다면 이해가 쉬우실 것 같습니다.
AuthenticationManager -> ProviderManager -> loadUserByUsername()
간단히 쉽게 보면 이렇게 로그인 로직을 수행 해 성공하면 Authentication 객체를 가져옵니다.
그 후에는 토큰을 발급받아 TokenDto를 리턴하는 로직입니다.
CustomUserDetailsService
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
@Transactional
public UserDetails loadUserByUsername(final String username) {
return userRepository.findOneWithAuthoritiesByUsername(username)
.map(this::createUser)
.orElseThrow(() -> new UsernameNotFoundException(username + " -> (을)를 찾을 수 없습니다."));
}
private org.springframework.security.core.userdetails.User createUser(String username, User user) {
return new org.springframework.security.core.userdetails.User(
user.getUsername(),
user.getPassword(),
user.getAuthorities());
}
}
Spring Security 에서 핵심 로그인 로직입니다.
user를 찾을수 없다면 익셉션을 발생시키게해놓았고 return 값을 UserDetails 해주어야하기 때문에 createUser메소드를 만들어서 반환을 해줍니다.
Controller
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping("/test-redirect")
public void testRedirect(HttpServletResponse response) throws IOException {
response.sendRedirect("/api/user");
}
@PostMapping("/signup")
public ResponseEntity<UserDto> signup(@Valid @RequestBody UserDto userDto) throws DuplicateMemberException {
return ResponseEntity.ok(userService.signup(userDto));
}
@GetMapping("/user")
@PreAuthorize("hasAnyRole('USER','ADMIN')") //user 와 admin 권한을 가진 사용자가 사용할 수 있게 설정
public ResponseEntity<UserDto> getMyUserInfo(HttpServletRequest request) {
return ResponseEntity.ok(userService.getMyUserWithAuthorities());
}
@GetMapping("/user/{username}")
@PreAuthorize("hasAnyRole('ADMIN')")//admin 권한을 가진 사용자만 사용을 할수 있게 한 설정
public ResponseEntity<UserDto> getUserInfo(@PathVariable String username) {
return ResponseEntity.ok(userService.getUserWithAuthorities(username));
}
@PostMapping("login") //login
public ResponseEntity authorize(@Valid @RequestBody LoginDto loginDto) {
TokenDto token = userService.login(loginDto);
HttpHeaders httpHeaders = new HttpHeaders();
httpHeaders.add(JwtFilter.AUTHORIZATION_HEADER, "Bearer " + token); // 토큰을 Header에 넣어주는 부분
return new ResponseEntity<>(token, httpHeaders, HttpStatus.OK);
}
}
Service에서 받은 token을 Header 값에 넣어서 Response 값을 보내줍니다.
반환값 확인용으로 body 에 담아서 보내봤습니다.
SecurityUtil
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SecurityUtil {
//SecurityContext 에서 authentication 을이용해 username 을 리턴해 주는 메소드
public static Optional<String> getCurrentUsername() {
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null) {
log.debug("Security Context에 인증 정보가 없습니다.");
return Optional.empty();
}
String username = null;
if (authentication.getPrincipal() instanceof UserDetails) {
UserDetails springSecurityUser = (UserDetails) authentication.getPrincipal();
username = springSecurityUser.getUsername();
} else if (authentication.getPrincipal() instanceof String) {
username = (String) authentication.getPrincipal();
}
return Optional.ofNullable(username);
}
}
Spring Security Context에 있는 인증 객체를 가져와 그 객체의 username을 반환하는 메소드이다.
이렇게 JWT토큰 로그인 방식을 구현을 해보았습니다. 중간에 jpa설정이나 enum으로 빼 줄만한 엔티티가있지만 그냥 학습목적이기 때문에 감안하고 해보았습니다.
'Spring > spring security' 카테고리의 다른 글
Spring Security Form 로그인 (0) | 2024.01.23 |
---|