📄요구 사항
이메일
, 패스워드
를 받아서, DB에 이메일
, 패스워드
, 회원 가입 시간
을 저장해야 한다.id
(PK, primary key)도 같이 Auto-increment 형식으로 저장돼야 한다.이메일
에 반드시 @
가 1개만 포함되어 있어야 한다.이메일
에 공백이 포함될 수 없다.이메일
이 존재할 수 없다.패스워드
에 공백이 포함될 수 없다.패스워드
는 8자 이상 15자 이하여야 한다.@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Email {
private static final String EMAIL_REGEX = "^[^\\s@]+@[^\\s@]+$";
private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);
@Column(name = "email")
private String value;
public Email(String value) {
validate(value);
this.value = value;
}
public void validate(String value){
if (!EMAIL_PATTERN.matcher(value).matches()) {
throw new InvalidEmailException();
}
}
}
Password
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Password {
private static final String PASSWORD_REGEX = "^(?!.*\\s).{8,15}$";
private static final Pattern PASSWORD_PATTERN = Pattern.compile(PASSWORD_REGEX);
@Column(name = "password")
private String value;
public Password(String value) {
validate(value);
this.value = value;
}
public void validate(String value) {
if (!PASSWORD_PATTERN.matcher(value).matches()) {
throw new InvalidPasswordException();
}
}
}
Roles
public enum Roles implements GrantedAuthority{
ADMIN, USER;
@Override
public String getAuthority() {
return name();
}
}
Member
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@Column(name = "member_id", nullable = false)
private Long id;
@Embedded
private Email email;
@Embedded
private Password password;
@CreatedDate
@Column(updatable = false)
private LocalDateTime regTime;
@Enumerated(EnumType.STRING)
private Roles roles;
@Builder
public Member(String email, String password, LocalDateTime regTime) {
this.email = new Email(email);
this.password = new Password(password);
this.regTime = LocalDateTime.now();
this.roles = Roles.USER;
}
}
MemberRepository
public interface MemberRepository extends JpaRepository<Member, Long> {
Optional<Member> findByEmailValueAndPasswordValue(String email, String password);
boolean existsByEmailValue(String email);
Member findByEmailValue(String email);
}
JPA namedQuery
로 추상 메서드를 생성했다.MemberService
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
public String signUp(MemberRequest memberRequest){
if(!memberRepository.existsByEmailValue(memberRequest.getEmail())) {
Member member = Member.builder()
.email(memberRequest.getEmail())
.password(memberRequest.getPassword())
.regTime(memberRequest.getRegTime())
.build();
memberRepository.save(member);
return "회원가입이 되었습니다!";
} else {
throw new DuplicateEmailException();
}
}
}
이메일
, 패스워드
값을 받는다.id
(PK, primary key)가 반드시 담겨있어야 한다.gradle
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.security:spring-security-test'
implementation group: 'com.auth0', name: 'java-jwt', version: '4.2.1'
implementation 'io.jsonwebtoken:jjwt:0.9.1'
SecurityConfig
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final JwtTokenProvider jwtTokenProvider;
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable();
//http.httpBasic().disable();
http.httpBasic().disable()
.authorizeRequests()
.antMatchers("/test").authenticated()
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/user/**").hasRole("USER")
.antMatchers("/**").permitAll()
.and()
.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),
UsernamePasswordAuthenticationFilter.class);
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
WebSecurityConfigurerAdapter
를 상속받는 방식은 Deprecated된 방식이기 때문에 수정 예정)JwtTokenProvider
@RequiredArgsConstructor
@Component
@Slf4j
public class JwtTokenProvider {
private String secretKey = "myprojectsecret";
// 토큰 유효시간 30분
private final long tokenValidTime = 30 * 60 * 1000L;
private final MemberDetailsService memberDetailsService;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
// JWT 토큰 생성
public LoginResponse createToken(Long userId, String roles) {
Claims claims = Jwts.claims();
claims.put("userId", userId);
claims.put("roles", roles);
Date now = new Date();
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now.getTime() + tokenValidTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return LoginResponse.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get("auth") == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection<? extends GrantedAuthority> authorities =
Arrays.stream(claims.get("auth").toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
UserDetails userDetails = memberDetailsService.loadUserByUsername(getUserPk(accessToken));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
// 토큰에서 회원 정보 추출
public String getUserPk(String token) {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject();
}
// Request의 Header에서 token 값을 가져옵니다.
public String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if(StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")){
return bearerToken.substring(7);
}
return null;
}
// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
} catch (MalformedJwtException e) {
log.info("Invalid JWT Token", e);
throw new JwtException("유효하지 않은 토큰입니다.");
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
throw new JwtException("기한이 만료된 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
throw new JwtException("지원하지 않는 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
throw new JwtException("claims 정보가 비어있습니다.");
} catch (SignatureException e){
log.info("JWT signature does not match locally computed signature.", e);
throw new JwtException("JWT 서명이 로컬로 산정된 서명과 일치하지 않습니다.");
}
return false;
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
MemberDetailsService
@RequiredArgsConstructor
@Service
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Member member = memberRepository.findByEmailValue(username);
if (member == null) {
throw new UsernameNotFoundException("해당 이메일을 찾을 수 없습니다.");
}
return org.springframework.security.core.userdetails.User
.withUsername(member.getEmail().getValue())
.authorities(member.getRoles())
.accountExpired(false)
.accountLocked(false)
.credentialsExpired(false)
.disabled(false)
.build();
}
}
JwtAuthenticationFilter
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
JwtTokenProvider
를 통해서 JWT를 Access Token을 생성할 수 있다.MemberService
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
private final MemberRepository memberRepository;
private final JwtTokenProvider jwtTokenProvider;
public LoginResponse logIn(LoginRequest loginRequest){
Member member = memberRepository.findByEmailValueAndPasswordValue(loginRequest.getEmail(),
loginRequest.getPassword()).orElseThrow(InvalidCredentialsException::new);
return jwtTokenProvider.createToken(member.getId(), member.getRoles().getAuthority());
}
}
JWTokenProvider
에서 생성된 토큰을 응답 DTO에 넣어 반환한다.id
(PK, primary key)를 활용해라.id
(PK, primary key), 이메일
, 회원 가입 시간
이 포함되어야 한다.패스워드
가 포함되면 안 된다.JwtTokenProvicer
public class JwtTokenProvider {
.....
// JWT 토큰 생성
public LoginResponse createToken(Long userId, String roles) {
Claims claims = Jwts.claims();
claims.put("userId", userId);
claims.put("roles", roles);
Date now = new Date();
String accessToken = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenValidTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
String refreshToken = Jwts.builder()
.setExpiration(new Date(now.getTime() + tokenValidTime))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
return LoginResponse.builder()
.grantType("Bearer")
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
.....
}
createToken
메서드를 통해서 Claims
객체를 생성하고 토큰에 추가 정보를 넣는다. 요구 사항에서 나온 id값과 역할을 payload에 저장하고 accessToken을 생성한다.// 토큰의 유효성 + 만료일자 확인
public boolean validateToken(String jwtToken) {
try {
Jwts.parser().setSigningKey(secretKey).parseClaimsJws(jwtToken);
} catch (MalformedJwtException e) {
log.info("Invalid JWT Token", e);
throw new JwtException("유효하지 않은 토큰입니다.");
} catch (ExpiredJwtException e) {
log.info("Expired JWT Token", e);
throw new JwtException("기한이 만료된 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("Unsupported JWT Token", e);
throw new JwtException("지원하지 않는 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT claims string is empty.", e);
throw new JwtException("claims 정보가 비어있습니다.");
} catch (SignatureException e){
log.info("JWT signature does not match locally computed signature.", e);
throw new JwtException("JWT 서명이 로컬로 산정된 서명과 일치하지 않습니다.");
}
return false;
}
validateToken
메서드를 통해서 Header에 JWT 토큰이 담겨있지 않다거나, Header에 담겨있는 JWT 토큰이 올바르지 않거나 조작되었거나, Header에 담겨있는 JWT 토큰의 만료기간이 지났다면 에러로 응답하게 만든다.결과
회원 가입으로 계정을 만든 후, 로그인을 했을 시 토큰으로 응답한다.
헤더의 Authorization에 Access Token을 넣고 찾고 싶은 계정의 id를 경로에 넣고 get요청을 했을 때 계정 정보로 응답한다.
📒 나의 생각