Skip to content

Commit

Permalink
[FEAT] 로그인 구현 (#22)
Browse files Browse the repository at this point in the history
Co-authored-by: kssumin <[email protected]>
  • Loading branch information
kssumin and kssumin authored Oct 1, 2023
1 parent 05a71fb commit 99d470b
Show file tree
Hide file tree
Showing 23 changed files with 509 additions and 3 deletions.
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,19 @@ dependencies {
// database
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'mysql:mysql-connector-java'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'

// infra
implementation 'org.springframework.boot:spring-boot-starter-security'

// jwt
implementation "io.jsonwebtoken:jjwt-api:${jsonwebtokenVersion}"
implementation "io.jsonwebtoken:jjwt-impl:${jsonwebtokenVersion}"
implementation "io.jsonwebtoken:jjwt-jackson:${jsonwebtokenVersion}"
}

test {
Expand Down
8 changes: 7 additions & 1 deletion resources/local-develop-environment/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,10 @@ services:
environment:
- ADMINER_DEFAULT_SERVER=golajuma-mysql8
- ADMINER_DESIGN=nette
- ADMINER_PLUGINS=tables-filter tinymce
- ADMINER_PLUGINS=tables-filter tinymce

redis-docker:
container_name: golajuma-redis
image: redis:latest
ports:
- "16379:6379"
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.kakao.golajuma.auth.domain.exception;

import com.kakao.golajuma.common.exception.BusinessException;
import org.springframework.http.HttpStatus;

public class NotFoundException extends BusinessException {

public NotFoundException(String message) {
super(message, HttpStatus.NOT_FOUND);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.kakao.golajuma.auth.domain.exception;

import com.kakao.golajuma.common.exception.BusinessException;
import org.springframework.http.HttpStatus;

public class NotValidToken extends BusinessException {

public NotValidToken(String message) {
super(message, HttpStatus.BAD_REQUEST);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.kakao.golajuma.auth.domain.model;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

@Getter
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Builder(toBuilder = true)
public class RefreshToken {
private String refreshToken;
private Long userId;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.kakao.golajuma.auth.domain.service;

import com.kakao.golajuma.auth.domain.exception.NotFoundException;
import com.kakao.golajuma.auth.domain.token.TokenProvider;
import com.kakao.golajuma.auth.domain.token.TokenResolver;
import com.kakao.golajuma.auth.infra.entity.UserEntity;
import com.kakao.golajuma.auth.infra.repository.UserRepository;
import com.kakao.golajuma.auth.web.dto.converter.TokenConverter;
import com.kakao.golajuma.auth.web.dto.request.LoginUserRequest;
import com.kakao.golajuma.auth.web.dto.response.TokenResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class LoginUserService {

private final TokenProvider tokenProvider;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final TokenConverter tokenConverter;
private final TokenResolver tokenResolver;
private final TokenService tokenService;

@Transactional
public TokenResponse execute(final LoginUserRequest request) {
UserEntity userEntity =
userRepository
.findByEmail(request.getEmail())
.orElseThrow(() -> new NotFoundException("존재하지 않는 이메일입니다."));

validPassword(request.getPassword(), userEntity);

String accessToken = tokenProvider.createAccessToken(userEntity.getId());
String refreshToken = tokenProvider.createRefreshToken(userEntity.getId());

tokenService.execute(userEntity.getId(), refreshToken);

return tokenConverter.from(
accessToken, tokenResolver.getExpiredDate(accessToken), refreshToken);
}

private void validPassword(final String requestPassword, final UserEntity userEntity) {
if (!matchPassword(requestPassword, userEntity.getPassword())) {
throw new NotFoundException("존재하지 않는 비밀번호입니다");
}
}

private boolean matchPassword(final String requestPassword, final String password) {
return passwordEncoder.matches(requestPassword, password);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.kakao.golajuma.auth.domain.service;

import com.kakao.golajuma.auth.domain.model.RefreshToken;
import com.kakao.golajuma.auth.infra.repository.RefreshTokenRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class TokenService {
private final RefreshTokenRepository refreshTokenRepository;

@Transactional
public void execute(Long userId, String token) {
RefreshToken refreshToken = RefreshToken.builder().refreshToken(token).userId(userId).build();
refreshTokenRepository.save(refreshToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.kakao.golajuma.auth.domain.token;

import io.jsonwebtoken.Header;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class TokenProvider {

private static final String USER_ID_CLAIM_KEY = "memberId";
private static final String USER_ROLE_CLAIM_KEY = "memberRole";
private final SecretKey accessSecretKey;

private final long accessValidTime;
private final SecretKey refreshSecretKey;

private final long refreshValidTime;

public TokenProvider(
@Value("${security.jwt.token.access.secretKey}") String accessSecretKey,
@Value("${security.jwt.token.access.validTime}") long accessValidTime,
@Value("${security.jwt.token.refresh.secretKey}") String refreshSecretKey,
@Value("${security.jwt.token.refresh.validTime}") long refreshValidTime) {
this.accessSecretKey = Keys.hmacShaKeyFor(accessSecretKey.getBytes(StandardCharsets.UTF_8));
this.accessValidTime = accessValidTime;
this.refreshSecretKey = Keys.hmacShaKeyFor(refreshSecretKey.getBytes(StandardCharsets.UTF_8));
this.refreshValidTime = refreshValidTime;
}

private String createToken(final Long userId, final SecretKey secretKey, final long validTime) {
final Date now = new Date();

return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE)
.claim(USER_ID_CLAIM_KEY, userId)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + validTime))
.signWith(secretKey)
.compact();
}

public String createAccessToken(final Long userId) {
return createToken(userId, accessSecretKey, accessValidTime);
}

public String createRefreshToken(final Long userId) {
return createToken(userId, refreshSecretKey, refreshValidTime);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.kakao.golajuma.auth.domain.token;

import com.kakao.golajuma.auth.domain.exception.NotValidToken;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class TokenResolver {

private static final String USER_ID_CLAIM_KEY = "memberId";
private static final String USER_ROLE_CLAIM_KEY = "memberRole";
private final SecretKey accessSecretKey;
private final SecretKey refreshSecretKey;

public TokenResolver(
@Value("${security.jwt.token.access.secretKey}") String accessSecretKey,
@Value("${security.jwt.token.refresh.secretKey}") String refreshSecretKey) {
this.accessSecretKey = Keys.hmacShaKeyFor(accessSecretKey.getBytes(StandardCharsets.UTF_8));
this.refreshSecretKey = Keys.hmacShaKeyFor(refreshSecretKey.getBytes(StandardCharsets.UTF_8));
}

private Claims getClaims(final String token) {
try {
return Jwts.parserBuilder()
.setSigningKey(accessSecretKey)
.build()
.parseClaimsJws(token)
.getBody();
} catch (Exception e) {
throw new NotValidToken("유효하지 않은 토큰입니다.");
}
}

public Date getExpiredDate(final String token) {
return getClaims(token).getExpiration();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.kakao.golajuma.auth.infra.repository;

import com.kakao.golajuma.auth.domain.model.RefreshToken;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Repository;

@Repository
@Slf4j
public class RefreshTokenRepository {

private RedisTemplate<Long, String> redisTemplate;

@Value("${redis.timeToLive}")
private long ttl;

public RefreshTokenRepository(final RedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}

public void save(final RefreshToken refreshToken) {
ValueOperations<Long, String> valueOperations = redisTemplate.opsForValue();
valueOperations.set(refreshToken.getUserId(), refreshToken.getRefreshToken());
redisTemplate.expire(refreshToken.getUserId(), ttl, TimeUnit.SECONDS);
}

public Optional<RefreshToken> findById(final Long userId) {
ValueOperations<Long, String> valueOperations = redisTemplate.opsForValue();
String refreshToken = valueOperations.get(userId);

if (Objects.isNull(refreshToken)) {
return Optional.empty();
}

return Optional.of(new RefreshToken(refreshToken, userId));
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.kakao.golajuma.auth.infra.repository;

import com.kakao.golajuma.auth.infra.entity.UserEntity;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<UserEntity, Long> {
boolean existsByEmail(String email);

boolean existsByNickname(String nickname);

Optional<UserEntity> findByEmail(String email);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.kakao.golajuma.auth.web.controller;

import com.kakao.golajuma.auth.domain.service.LoginUserService;
import com.kakao.golajuma.auth.web.dto.request.LoginUserRequest;
import com.kakao.golajuma.auth.web.dto.response.TokenResponse;
import com.kakao.golajuma.common.support.respnose.ApiResponse;
import com.kakao.golajuma.common.support.respnose.ApiResponseBody.SuccessBody;
import com.kakao.golajuma.common.support.respnose.ApiResponseGenerator;
import com.kakao.golajuma.common.support.respnose.MessageCode;
import javax.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
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
@RequiredArgsConstructor
@RequestMapping("/api/auth")
public class LoginController {
private static final String REFRESH_TOKEN = "refreshToken";
private static final int REFRESH_TOKEN_EXPIRATION = 7 * 24 * 60 * 60;
private final LoginUserService loginUserUseCase;

@PostMapping("/login")
public ApiResponse<SuccessBody<TokenResponse>> signIn(
@RequestBody @Valid LoginUserRequest request) {
final TokenResponse tokenResponse = loginUserUseCase.execute(request);
final ResponseCookie cookie = putTokenInCookie(tokenResponse);
return ApiResponseGenerator.success(
tokenResponse, HttpStatus.OK, MessageCode.CREATE, cookie.toString());
}

private ResponseCookie putTokenInCookie(final TokenResponse tokenResponse) {
return ResponseCookie.from(REFRESH_TOKEN, tokenResponse.getRefreshToken())
.maxAge(REFRESH_TOKEN_EXPIRATION)
.path("/")
.sameSite("None")
.secure(true)
.httpOnly(true)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.kakao.golajuma.auth.web.dto.converter;

import com.kakao.golajuma.auth.web.dto.response.TokenResponse;
import java.util.Date;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class TokenConverter {

public TokenResponse from(String accessToken, Date expiredTime, String refreshToken) {
return TokenResponse.builder()
.accessToken(accessToken)
.expiredTime(expiredTime)
.refreshToken(refreshToken)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.kakao.golajuma.auth.web.dto.request;

import com.kakao.golajuma.auth.web.supplier.EmailSupplier;
import com.kakao.golajuma.common.marker.AbstractRequestDto;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder(toBuilder = true)
public class LoginUserRequest implements AbstractRequestDto, EmailSupplier {

@NotNull @Email private String email;

@NotNull
@Size(min = 8)
private String password;
}
Loading

0 comments on commit 99d470b

Please sign in to comment.