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: POST /user/login API 구현 #50

Merged
merged 18 commits into from
Dec 21, 2023
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
7 changes: 6 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,14 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.mysql:mysql-connector-j'
compileOnly 'org.projectlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
implementation 'com.mysql:mysql-connector-j'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.testcontainers:testcontainers:1.19.3'
testImplementation 'org.testcontainers:junit-jupiter:1.19.3'
Expand Down
44 changes: 44 additions & 0 deletions src/main/java/in/koreatech/koin/auth/JwtProvider.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package in.koreatech.koin.auth;

import in.koreatech.koin.domain.user.User;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.security.Key;
import java.time.Instant;
import java.util.Base64;
import java.util.Date;
import javax.crypto.SecretKey;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

@Component
public class JwtProvider {

@Value("${jwt.secret-key}")
private String secretKey;

@Value("${jwt.access-token.expiration-time}")
private Long expirationTime;

public String createToken(User user) {
if (user == null) {
throw new IllegalArgumentException("존재하지 않는 사용자입니다.");
}

Key key = getSecretKey();
return Jwts.builder()
.signWith(key)
.header()
.add("typ", "JWT")
.add("alg", key.getAlgorithm())
.and()
.claim("id", user.getId())
.expiration(new Date(Instant.now().toEpochMilli() + expirationTime))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

시간 연산은 다양한 곳에서 활용될 것 같은데, 추후에라도 시간 계산을 별도 유틸리티 객체로 빼는 건 어떨까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금으로서는 어떻게 추상화시켜야할지 감이 잘 안오네용
추후에 동일한 연산이 사용된다면 분리해보면 좋을 것 같아요! 👍

.compact();
}

private SecretKey getSecretKey() {
String encoded = Base64.getEncoder().encodeToString(secretKey.getBytes());
return Keys.hmacShaKeyFor(encoded.getBytes());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package in.koreatech.koin.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler
public ResponseEntity<String> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.warn(e.getMessage());
return ResponseEntity.badRequest().body(e.getMessage());
}

@ExceptionHandler
public ResponseEntity<String> handleIllegalArgumentException(IllegalArgumentException e) {
log.warn(e.getMessage());
return ResponseEntity.badRequest().body(e.getMessage());
}
}
26 changes: 26 additions & 0 deletions src/main/java/in/koreatech/koin/controller/UserController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package in.koreatech.koin.controller;

import in.koreatech.koin.dto.UserLoginRequest;
import in.koreatech.koin.dto.UserLoginResponse;
import in.koreatech.koin.service.UserService;
import jakarta.validation.Valid;
import java.net.URI;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class UserController {

private final UserService userService;

@PostMapping("/user/login")
public ResponseEntity<UserLoginResponse> login(@RequestBody @Valid UserLoginRequest request) {
UserLoginResponse response = userService.login(request);
return ResponseEntity.created(URI.create("/"))
.body(response);
}
}
6 changes: 3 additions & 3 deletions src/main/java/in/koreatech/koin/domain/Member.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class Member extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private Long id;

@Size(max = 50)
@NotNull
Expand Down Expand Up @@ -55,8 +55,8 @@ public class Member extends BaseEntity {
private Boolean isDeleted = false;

@Builder
public Member(String name, String studentNumber, Long trackId, String position, String email, String imageUrl,
Boolean isDeleted) {
private Member(String name, String studentNumber, Long trackId, String position, String email, String imageUrl,
Boolean isDeleted) {
this.name = name;
this.studentNumber = studentNumber;
this.trackId = trackId;
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/in/koreatech/koin/domain/user/Student.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package in.koreatech.koin.domain.user;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Size;
import lombok.Getter;

@Getter
@Entity
@Table(name = "students")
public class Student {

@Id
private Long userId;

@Size(max = 255)
@Column(name = "anonymous_nickname")
private String anonymousNickname = "익명_" + System.currentTimeMillis();

@Size(max = 20)
@Column(name = "student_number", length = 20)
private String studentNumber;

@Size(max = 50)
@Column(name = "major", length = 50)
private String department;

@Column(name = "identity")
@Enumerated(EnumType.STRING)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

private UserIdentity userIdentity;

@Column(name = "is_graduated")
private Boolean isGraduated;
}
123 changes: 123 additions & 0 deletions src/main/java/in/koreatech/koin/domain/user/User.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package in.koreatech.koin.domain.user;

import in.koreatech.koin.domain.BaseEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Entity
@Table(name = "users")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class User extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@NotNull
@Lob
@Column(name = "password", nullable = false)
private String password;

@Size(max = 50)
@Column(name = "nickname", length = 50)
private String nickname;

@Size(max = 50)
@Column(name = "name", length = 50)
private String name;

@Size(max = 20)
@Column(name = "phone_number", length = 20)
private String phoneNumber;

@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "user_type", nullable = false, length = 20)
private UserType userType;

@Size(max = 100)
@NotNull
@Column(name = "email", nullable = false, length = 100)
private String email;

@Column(name = "gender")
@Enumerated(value = EnumType.ORDINAL)
private UserGender gender;

@NotNull
@Column(name = "is_authed", nullable = false)
private Boolean isAuthed = false;

@Column(name = "last_logged_at")
private LocalDateTime lastLoggedAt;

@Size(max = 255)
@Column(name = "profile_image_url")
private String profileImageUrl;

@NotNull
@Column(name = "is_deleted", nullable = false)
private Boolean isDeleted = false;

@Size(max = 255)
@Column(name = "auth_token")
private String authToken;

@Size(max = 255)
@Column(name = "auth_expired_at")
private String authExpiredAt;

@Size(max = 255)
@Column(name = "reset_token")
private String resetToken;

@Size(max = 255)
@Column(name = "reset_expired_at")
private String resetExpiredAt;

@Builder
public User(String password, String nickname, String name, String phoneNumber, UserType userType,
String email,
UserGender gender, Boolean isAuthed, LocalDateTime lastLoggedAt, String profileImageUrl,
Boolean isDeleted,
String authToken, String authExpiredAt, String resetToken, String resetExpiredAt) {
this.password = password;
this.nickname = nickname;
this.name = name;
this.phoneNumber = phoneNumber;
this.userType = userType;
this.email = email;
this.gender = gender;
this.isAuthed = isAuthed;
this.lastLoggedAt = lastLoggedAt;
this.profileImageUrl = profileImageUrl;
this.isDeleted = isDeleted;
this.authToken = authToken;
this.authExpiredAt = authExpiredAt;
this.resetToken = resetToken;
this.resetExpiredAt = resetExpiredAt;
}

public boolean isSamePassword(String password) {
return this.password.equals(password);
}

public void updateLastLoggedTime(LocalDateTime lastLoggedTime) {
lastLoggedAt = lastLoggedTime;
}
}
7 changes: 7 additions & 0 deletions src/main/java/in/koreatech/koin/domain/user/UserGender.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package in.koreatech.koin.domain.user;

public enum UserGender {
MAN,
WOMAN,
;
Comment on lines +4 to +6
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

마지막 enum에는 ,를 지워도 되지 않을까요?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다음 아티클을 읽고 후행쉼표를 사용해봤습니다.
https://medium.com/@nikgraf/why-you-should-enforce-dangling-commas-for-multiline-statements-d034c98e36f8

간단하게 요약하면 추후 추가적으로 생기는 Enum 프로퍼티에 대해 PR 제출 시 라인 변경사항이 줄어든다는 장점이 있다고 합니다.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다음 항목을 입력하기도 편하고, pr을 했을 때 새로운 항목만 표시된다는 장점이 있군요..! 😮

}
23 changes: 23 additions & 0 deletions src/main/java/in/koreatech/koin/domain/user/UserIdentity.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package in.koreatech.koin.domain.user;

import lombok.Getter;

/**
* 신원 (0: 학생, 1: 대학원생, 2: 교수, 3: 교직원, 4: 졸업생, 5: 점주)
*/
@Getter
public enum UserIdentity {
UNDERGRADUATE("학부생"),
GRADUATE("대학원생"),
PROFESSOR("교수"),
STAFF("교직원"),
ALUMNI("졸업생"),
OWNER("점주"),
;

private final String value;

UserIdentity(String value) {
this.value = value;
}
}
32 changes: 32 additions & 0 deletions src/main/java/in/koreatech/koin/domain/user/UserToken.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package in.koreatech.koin.domain.user;

import java.util.concurrent.TimeUnit;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;
import org.springframework.data.redis.core.TimeToLive;

@Getter
@RedisHash("refreshToken")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인해보니 Redis의 HASH 자료구조 이용해서 데이터를 보관하는 것을 확인하였는데
기존 데이터와 키값(user:{ID}), 저장방식(STRING을 이용해 token 보관)과 호환이 안될 것 같습니다!

따라서 추후 제작될 리프레시 API에서 redis에 저장된 리프레시 토큰과 사용자가 제시한 리프레시 토큰을 비교해 검증하는 로직에서

  1. 기존 키값이 존재하는 경우
  2. 기존 키값이 없고, 새로운 키값(refreshToken:{ID})이 있는 경우
  3. 두 키값 모두 없는 경우

모두 검사할 수 있어야 할 것 같아요

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/user/refresh API 에 해당 내용 추가했습니다

public class UserToken {

private static final long REFRESH_TOKEN_EXPIRE_DAY = 14L;

@Id
private Long id;

private final String refreshToken;

@TimeToLive(unit = TimeUnit.DAYS)
private final Long expiration;

private UserToken(Long id, String refreshToken, Long expiration) {
this.id = id;
this.refreshToken = refreshToken;
this.expiration = expiration;
}

public static UserToken create(Long userId, String refreshToken) {
return new UserToken(userId, refreshToken, REFRESH_TOKEN_EXPIRE_DAY);
}
}
18 changes: 18 additions & 0 deletions src/main/java/in/koreatech/koin/domain/user/UserType.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package in.koreatech.koin.domain.user;

import lombok.Getter;

@Getter
public enum UserType {
STUDENT("STUDENT", "학생"),
USER("USER", "사용자"),
;

private final String value;
private final String description;

UserType(String value, String description) {
this.value = value;
this.description = description;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public static InnerTechStackResponse from(TechStack techStack) {
@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class)
public static class InnerMemberResponse {

private Integer id;
private Long id;
private String name;
private String studentNumber;
private String position;
Expand Down
Loading
Loading