Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5f55b98
feat: add user registration and login infrastructure with JPA entities,
daydream-03 Aug 3, 2025
30674da
feat: implement user registration and login services
daydream-03 Aug 3, 2025
aa833ba
feat: add REST API endpoints for user management
daydream-03 Aug 3, 2025
4e9eac1
Remove redundant permitAll matchers
daydream-03 Aug 9, 2025
b5d6bab
Replace wildcard import with explicit annotations
daydream-03 Aug 9, 2025
571071e
Accept user registration via POST /api/v1/users
daydream-03 Aug 9, 2025
1eeb0b3
Add Bean validation to registration
daydream-03 Aug 9, 2025
b453612
Apply Lombok to DTOs and User Entity
daydream-03 Aug 9, 2025
95b0752
Annotate UserService.save with @Transactional
daydream-03 Aug 9, 2025
3485dc7
Return status code 201 Created for registration
daydream-03 Aug 9, 2025
65ce793
Add Lombok @Builder constructor to User entity
daydream-03 Aug 9, 2025
a681ba8
Change table name to "user"
daydream-03 Aug 9, 2025
0f63580
Remove unnecessary lines
daydream-03 Aug 9, 2025
378e238
Return structured login response
daydream-03 Aug 9, 2025
dd5f1c3
Add UserLoginResponse DTO with Lombok
daydream-03 Aug 9, 2025
0c0e967
Rely on DB UNIQUE constraint and remove pre-insert validation method
daydream-03 Aug 9, 2025
62a4eb0
Add JWT dependencies to build.gradle
daydream-03 Aug 10, 2025
734a376
Implement JWT token provider that generates and validates token
daydream-03 Aug 10, 2025
5888963
Add JWT authentication filter for request processing
daydream-03 Aug 10, 2025
737ca66
Add CustomUserDetailsService for Spring Security authentication
daydream-03 Aug 10, 2025
55927ec
Configure Spring Security for JWT-based stateless authentication
daydream-03 Aug 10, 2025
f9ffcb1
Update UserLoginResponse to return JWT token instead of user info
daydream-03 Aug 10, 2025
b3c73ad
Replace login logic with JWT token-based authentication in UserContro…
daydream-03 Aug 10, 2025
585ab89
Remove login method from UserService
daydream-03 Aug 10, 2025
aefbcaf
Update docker-compose.yml
daydream-03 Aug 10, 2025
238dc33
Add JWT configuration to application.properties
daydream-03 Aug 10, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ dependencies {
// Database
implementation 'mysql:mysql-connector-java:8.0.33'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'

// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
26 changes: 19 additions & 7 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
version: '3.8'

services:
app:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://db:3306/mydb
SPRING_DATASOURCE_USERNAME: ${MYSQL_USER}
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
depends_on:
- db
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://db:3306/${MYSQL_DATABASE}
- SPRING_DATASOURCE_USERNAME=${MYSQL_USER}
- SPRING_DATASOURCE_PASSWORD=${MYSQL_PASSWORD}
- JWT_SECRET=${JWT_SECRET}
- JWT_EXPIRATION=${JWT_EXPIRATION}
volumes:
- .:/workspace
working_dir: /workspace

db:
image: mysql:8.0
environment:
MYSQL_DATABASE: mydb
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE}
MYSQL_USER: ${MYSQL_USER}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
ports:
- "3306:3306"
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql

volumes:
mysql_data:
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package com.example.tech_interview_buddy.config;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
@RequiredArgsConstructor
Copy link
Collaborator

Choose a reason for hiding this comment

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

@RequiredArgsConstructor 의 단점은 어떤게 있을까요 ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

  1. 명시적 생성자가 없어서 어떤 필드가 필수인지 한눈에 파악하기 어렵습니다.
  2. 선택적으로 설정될 수 있는 필드에 대해서는 생성자를 자동으로 생성해주지 않기 때문에 직접 추가해야합니다.
  3. Autowired는 스프링이 제공 주체이지만, RequiredArgsConstructor는 Lombok에서 제공하기 때문에 Lombok 의존성 추가가 강제됩니다.

크게 3가지 정도 학습해서 답변드립니다

public class JwtAuthenticationFilter extends OncePerRequestFilter {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Jwt를 쓰기로 한 이유가 있을까요 ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

솔직히 말씀드리면 토큰이 필요하다는 생각이 들었을 때 가장 먼저 떠오른게 JWT여서-이긴 했는데요,
토큰 안에 필요한 정보들을 선별해 담을 수 있고, 서버 어딘가에 인증정보를 저장해야하는 세션 기반 방식과는 다르게 stateless하다는 장점들이 있다고 알고 있습니다.


private final JwtTokenProvider jwtTokenProvider;
private final UserDetailsService userDetailsService;

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
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
Collaborator Author

Choose a reason for hiding this comment

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

수정하겠습니다

FilterChain filterChain) throws ServletException, IOException {

if (request.getRequestURI().equals("/api/v1/users/login")) {
filterChain.doFilter(request, response);
return;
}

try {
String jwt = getJwtFromRequest(request);

if (jwt != null && jwtTokenProvider.validateToken(jwt)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

null체크같은 경우는 조건이 맞지 않는 경우 얼리리턴을 해주는게 가독성에 도움이 될 것 같아요 ~

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

수정하겠습니다

String username = jwtTokenProvider.getUsernameFromToken(jwt);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);

UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));

SecurityContextHolder.getContext().setAuthentication(authentication);
}
} catch (Exception ex) {
logger.error("Could not set user authentication in security context", ex);
}

filterChain.doFilter(request, response);
}

private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
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
Collaborator Author

Choose a reason for hiding this comment

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

수정하겠습니다

return bearerToken.substring(7);
}
return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.example.tech_interview_buddy.config;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.util.Date;

@Component
public class JwtTokenProvider {

@Value("${jwt.secret:defaultSecretKey}")
private String secretKey;

@Value("${jwt.expiration:86400000}")
private long validityInMilliseconds;

private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes());
}

public String generateToken(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
Date now = new Date();
Date expiryDate = new Date(now.getTime() + validityInMilliseconds);

return Jwts.builder()
.setSubject(userDetails.getUsername())
.setIssuedAt(now)
.setExpiration(expiryDate)
.signWith(getSigningKey(), SignatureAlgorithm.HS512)
.compact();
}

public String getUsernameFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();

return claims.getSubject();
}

public boolean validateToken(String token) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package com.example.tech_interview_buddy.config;

import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
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
Collaborator Author

Choose a reason for hiding this comment

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

네 직접 포스트맨으로 회원가입, 로그인 동작하는 것 확인하고 DB에도 사용자 레코드 확인했습니다

@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final UserDetailsService userDetailsService;

@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}

@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/v1/users/**").permitAll()
.anyRequest().authenticated()
)
.userDetailsService(userDetailsService)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);

return http.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.example.tech_interview_buddy.controller;

import com.example.tech_interview_buddy.config.JwtTokenProvider;
import com.example.tech_interview_buddy.service.UserService;
import com.example.tech_interview_buddy.dto.request.UserCreateRequest;
import com.example.tech_interview_buddy.dto.request.UserLoginRequest;
import com.example.tech_interview_buddy.dto.response.UserLoginResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import jakarta.validation.Valid;

@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
public class UserController {

private final UserService userService;
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationManager authenticationManager;

@GetMapping
public String getUsers() {
return "users";
}

@PostMapping("")
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
Collaborator Author

Choose a reason for hiding this comment

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

수정하겠습니다

@ResponseStatus(HttpStatus.CREATED)
Copy link
Collaborator

Choose a reason for hiding this comment

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

이건 좀 생각해 볼 필요가 있는데요, 클라이언트 사이드에서 성공 응답을 200번대 로 잡는게 아니라 200 을 잡는 경우가 많아서 성공 응답을 세분화하진 않는 편이에요. 물론 잘못된건 아니고 제가 겪지 못한 부분에서 나누는게 의미가 있을 수도 있겠지만, 제가 경험한 범위에서는 그랬었고, 당장 생각해봤을때 성공 응답을 세분화하는게 의미가 있나 싶긴 하네요!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

성공 응답 세분화까지는 안하는 방향으로 수정하겠습니다

public ResponseEntity<Void> registerUser(@Valid @RequestBody UserCreateRequest userCreateRequest) {
userService.save(userCreateRequest);
return ResponseEntity.status(HttpStatus.CREATED).build();
}

@PostMapping("/login")
public ResponseEntity<UserLoginResponse> loginUser(@Valid @RequestBody UserLoginRequest userLoginRequest) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

요거는 몇번 말씀드렸는데, 그냥 응답에 성공하면 기본적으로 200 응답이 내려가기때문에 굳이 응답을 감싸진 않아도 될 것 같아요

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

코드를 쓰고 지우고 반복하는 과정에서 또 되살아난 것 같습니다. 주의하겠습니다!

Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(userLoginRequest.getUsername(), userLoginRequest.getPassword())
);

SecurityContextHolder.getContext().setAuthentication(authentication);
String jwt = jwtTokenProvider.generateToken(authentication);

return ResponseEntity.ok(new UserLoginResponse(jwt));
}
}
42 changes: 42 additions & 0 deletions src/main/java/com/example/tech_interview_buddy/domain/User.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.example.tech_interview_buddy.domain;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Builder;


@Getter
@NoArgsConstructor
@Entity
@Table(name = "user", uniqueConstraints = {
@UniqueConstraint(columnNames = "username"),
@UniqueConstraint(columnNames = "email")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private String username;

@Column(nullable = false)
private String password;

@Column(nullable = false)
private String email;

@Builder
public User(String username, String password, String email) {
this.username = username;
this.password = password;
this.email = email;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.example.tech_interview_buddy.dto.request;

import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserCreateRequest {
@NotBlank(message = "Username is required")
private String username;

@NotBlank(message = "Email is required")
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
Collaborator Author

Choose a reason for hiding this comment

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

컨트롤러, 서비스, 도메인 3개의 레이어에서 각각 유효성검사를 수행하되, dto에서까지는 수행하지 않는 것으로 이해했습니다.
수정하겠습니다.

@Email(message = "Invalid email address")
private String email;

@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters long")
private String password;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.example.tech_interview_buddy.dto.request;

import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;

import jakarta.validation.constraints.NotBlank;

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginRequest {
@NotBlank(message = "Username is required")
private String username;

@NotBlank(message = "Password is required")
private String password;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.example.tech_interview_buddy.dto.response;

import lombok.Getter;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class UserLoginResponse {
private String token;
}
Loading
Loading