Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: jwt token 발급, 검증 필터 추가 #23

Merged
merged 7 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0")

// JWT
implementation("io.jsonwebtoken:jjwt-api:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6")
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6")

// Database
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.postgresql:postgresql:42.7.3")
Expand Down
20 changes: 20 additions & 0 deletions src/main/java/com/dnd/runus/auth/config/TokenConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.dnd.runus.auth.config;

import com.dnd.runus.auth.token.strategry.JwtTokenStrategy;
import com.dnd.runus.auth.token.strategry.TokenStrategy;
import io.jsonwebtoken.Jwts;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.time.Duration;

@Configuration
class TokenConfig {
@Bean("accessTokenStrategy")
TokenStrategy accessTokenStrategy(
@Value("${app.auth.token.access.expiration}") Duration accessExpiration,
@Value("${app.auth.token.access.secret-key}") String secretKey) {
return JwtTokenStrategy.of(secretKey, accessExpiration, Jwts.SIG.HS256);
}
}
23 changes: 23 additions & 0 deletions src/main/java/com/dnd/runus/auth/token/TokenProviderModule.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.dnd.runus.auth.token;

import com.dnd.runus.auth.token.access.AccessTokenProvider;
import com.dnd.runus.auth.token.dto.AuthTokenDto;
import com.dnd.runus.global.constant.AuthConstant;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class TokenProviderModule {
private final AccessTokenProvider accessTokenProvider;
// TODO: RefreshTokenProvider refreshTokenProvider;

public AuthTokenDto generate(String subject) {
String accessToken = accessTokenProvider.issueToken(subject);

log.info("Login success, sub: {}", subject);
return new AuthTokenDto(accessToken, AuthConstant.TOKEN_TYPE);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.dnd.runus.auth.token.access;

import com.dnd.runus.auth.token.dto.AuthTokenClaimDto;
import com.dnd.runus.auth.token.strategry.TokenStrategy;
import com.dnd.runus.global.constant.AuthConstant;
import io.micrometer.common.util.StringUtils;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Component;

@Component
public class AccessTokenProvider {
private final TokenStrategy strategy;

public AccessTokenProvider(@Qualifier("accessTokenStrategy") TokenStrategy tokenStrategy) {
this.strategy = tokenStrategy;
}

public String resolveToken(String headerAuth) {
if (StringUtils.isNotBlank(headerAuth) && headerAuth.startsWith(AuthConstant.TOKEN_TYPE)) {
return headerAuth.substring(AuthConstant.TOKEN_TYPE.length()).trim();
}
return "";
}

public String issueToken(String subject) {
return strategy.generateToken(subject);
}

public AuthTokenClaimDto getClaims(String token) {
return strategy.getClaims(token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.dnd.runus.auth.token.dto;

import java.time.Instant;

public record AuthTokenClaimDto(
String subject,
Instant expireAt
) {
}
7 changes: 7 additions & 0 deletions src/main/java/com/dnd/runus/auth/token/dto/AuthTokenDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.dnd.runus.auth.token.dto;

public record AuthTokenDto(
String accessToken,
String type
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package com.dnd.runus.auth.token.strategry;

import com.dnd.runus.auth.exception.AuthException;
import com.dnd.runus.auth.token.dto.AuthTokenClaimDto;
import com.dnd.runus.global.exception.type.ErrorType;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.MacAlgorithm;
import io.jsonwebtoken.security.SecureDigestAlgorithm;
import io.jsonwebtoken.security.SignatureException;
import io.jsonwebtoken.*;
import lombok.RequiredArgsConstructor;

import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
import javax.crypto.SecretKey;

import static com.dnd.runus.global.exception.type.ErrorType.*;
import static lombok.AccessLevel.PRIVATE;

@RequiredArgsConstructor(access = PRIVATE)
public final class JwtTokenStrategy implements TokenStrategy {
private final Duration expiration;
private final SecretKey secretKey;
private final SecureDigestAlgorithm<? super SecretKey, ?> algorithm;

private static final Map<Class<? extends Exception>, ErrorType> ERROR_TYPES = Map.of(
ExpiredJwtException.class, EXPIRED_ACCESS_TOKEN,
MalformedJwtException.class, MALFORMED_ACCESS_TOKEN,
SignatureException.class, TAMPERED_ACCESS_TOKEN,
UnsupportedJwtException.class, UNSUPPORTED_JWT_TOKEN);

public static JwtTokenStrategy of(String rawSecretKey, Duration expiration, MacAlgorithm algorithm) {
byte[] keyBytes = Base64.getEncoder().encode(rawSecretKey.getBytes()); // 비공개 키를 Base64 인코딩
SecretKey secretKey = Keys.hmacShaKeyFor(keyBytes);
return new JwtTokenStrategy(expiration, secretKey, algorithm);
}

public String generateToken(String subject) {
Instant now = Instant.now();
Instant expireAt = now.plus(expiration);

return Jwts.builder()
.header()
.add(createHeader())
.and()
.issuer("runus")
.issuedAt(Date.from(now))
.expiration(Date.from(expireAt))
.claims(createClaims(subject, expireAt))
.signWith(secretKey, algorithm)
.compact();
}

public AuthTokenClaimDto getClaims(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return new AuthTokenClaimDto(
claims.getSubject(), claims.getExpiration().toInstant());
} catch (JwtException jwtException) {
ErrorType type = ERROR_TYPES.getOrDefault(jwtException.getClass(), FAILED_AUTHENTICATION);
throw new AuthException(type, jwtException.getMessage());
}
}

private static Map<String, Object> createHeader() {
return Map.of(Header.TYPE, Header.JWT_TYPE);
}

private static Map<String, Object> createClaims(String subject, Instant expireAt) {
return Map.of(Claims.SUBJECT, subject, Claims.EXPIRATION, Date.from(expireAt));
}

private static class Header {
static final String TYPE = "typ";
static final String JWT_TYPE = "JWT";
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.dnd.runus.auth.token.strategry;

import com.dnd.runus.auth.token.dto.AuthTokenClaimDto;

public interface TokenStrategy {
/**
* 새로운 토큰을 발행
*
* @param subject - 토큰 구분 대상
* @return - 발급한 토큰
*/
String generateToken(String subject);

AuthTokenClaimDto getClaims(String token);
}
70 changes: 70 additions & 0 deletions src/main/java/com/dnd/runus/auth/userdetails/AuthUserDetails.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.dnd.runus.auth.userdetails;

import com.dnd.runus.global.constant.MemberRole;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.ToString;
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 static lombok.AccessLevel.PRIVATE;

@Getter
@ToString
@RequiredArgsConstructor(access = PRIVATE, staticName = "of")
public class AuthUserDetails implements UserDetails {
private final long id;
private final Collection<? extends GrantedAuthority> authorities;

public static AuthUserDetails of(long memberId, MemberRole role) {
return AuthUserDetails.of(memberId, List.of(new SimpleGrantedAuthority(role.getValue())));
}

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}

@Override
public String getPassword() {
return null;
}

@Override
public String getUsername() {
return String.valueOf(id);
}

@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 instanceof UserDetails)) return false;
AuthUserDetails user = (AuthUserDetails) o;
return Objects.equals(id, user.id);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.dnd.runus.auth.userdetails;

import com.dnd.runus.auth.exception.AuthException;
import com.dnd.runus.domain.member.entity.Member;
import com.dnd.runus.domain.member.repository.MemberRepository;
import com.dnd.runus.global.exception.type.ErrorType;
import lombok.RequiredArgsConstructor;
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
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserDetailsServiceImpl implements UserDetailsService {
private final MemberRepository memberRepository;

@Override
public UserDetails loadUserByUsername(String identity) throws UsernameNotFoundException {
try {
long memberId = Long.parseLong(identity);
Member member = memberRepository
.findById(memberId)
.orElseThrow(
() -> new AuthException(ErrorType.FAILED_AUTHENTICATION, "Member not found: " + memberId));
return AuthUserDetails.of(memberId, member.getRole());
} catch (NumberFormatException exception) {
throw new UsernameNotFoundException(identity);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
package com.dnd.runus.presentation.config;

import com.dnd.runus.presentation.filter.AuthenticationCheckFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.ForwardedHeaderFilter;

@RequiredArgsConstructor
@Configuration
public class SecurityFilterConfig {
@Bean
AuthenticationCheckFilter authenticationCheckFilter() {
return new AuthenticationCheckFilter();
}

@Bean
ForwardedHeaderFilter forwardedHeaderFilter() {
return new ForwardedHeaderFilter();
Expand Down
Loading