feat: 로그인

This commit is contained in:
2025-12-16 11:59:12 +09:00
parent e973b74c47
commit ca74cc42ed
15 changed files with 300 additions and 52 deletions

View File

@@ -1,6 +1,6 @@
package com.audio.common; package com.audio.common;
import com.audio.config.properties.TokenProperties; import com.audio.core.auth.application.userDetail.User;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.JwtParser;
@@ -24,7 +24,7 @@ public class TokenProvider {
private final Duration refreshToken; private final Duration refreshToken;
/** Access Token 생성 */ /** Access Token 생성 */
public String createAccessToken(Long accountId, List<String> roles) { public String createUserAccessToken(Long accountId) {
Instant now = Instant.now(); Instant now = Instant.now();
Instant exp = now.plus(accessToken); Instant exp = now.plus(accessToken);
@@ -34,13 +34,13 @@ public class TokenProvider {
.setIssuedAt(Date.from(now)) .setIssuedAt(Date.from(now))
.setExpiration(Date.from(exp)) .setExpiration(Date.from(exp))
.claim("typ", "access") .claim("typ", "access")
.claim("roles", roles) .claim("roles", User.roles)
.signWith(key, SignatureAlgorithm.HS256) .signWith(key, SignatureAlgorithm.HS256)
.compact(); .compact();
} }
/** Refresh Token 생성 */ /** Refresh Token 생성 */
public String createRefreshToken(Long accountId) { public String createUserRefreshToken(Long accountId) {
Instant now = Instant.now(); Instant now = Instant.now();
Instant exp = now.plus(refreshToken); Instant exp = now.plus(refreshToken);
@@ -67,9 +67,17 @@ public class TokenProvider {
/** Access 토큰인지(typ=access)까지 확인 */ /** Access 토큰인지(typ=access)까지 확인 */
public boolean validateAccessToken(String token) { public boolean validateAccessToken(String token) {
try { try {
log.info("액세스 토큰 검증");
log.info(token);
Claims claims = getClaims(token); Claims claims = getClaims(token);
log.info("sub: {}", claims.getSubject());
log.info("typ: {}", claims.get("typ", String.class));
return "access".equals(claims.get("typ", String.class)); return "access".equals(claims.get("typ", String.class));
} catch (JwtException | IllegalArgumentException e) { } catch (JwtException | IllegalArgumentException e) {
log.error(e.getMessage());
return false; return false;
} }
} }
@@ -102,7 +110,7 @@ public class TokenProvider {
} }
private String stripBearer(String token) { private String stripBearer(String token) {
if (token == null) return null; if (token == null || !token.startsWith("Bearer ")) return null;
return token.startsWith("Bearer ") ? token.substring(7) : token; return token.substring(7);
} }
} }

View File

@@ -1,4 +1,4 @@
package com.audio.config.filter; package com.audio.config.security;
import com.audio.common.TokenProvider; import com.audio.common.TokenProvider;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
@@ -8,19 +8,18 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.util.AntPathMatcher; import org.springframework.util.AntPathMatcher;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@RequiredArgsConstructor @RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter { public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider; private final TokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
private final AntPathMatcher pathMatcher = new AntPathMatcher(); private final AntPathMatcher pathMatcher = new AntPathMatcher();
private final String[] permitAllPatterns; private final String[] permitAllPatterns;
@@ -28,22 +27,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
protected boolean shouldNotFilter(HttpServletRequest request) { protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI(); String path = request.getRequestURI();
for (String pattern : permitAllPatterns) { for (String pattern : permitAllPatterns) {
if (pathMatcher.match(pattern, path)) return true; if (pathMatcher.match(pattern, path)) {
return true;
}
} }
return false; return false;
} }
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
HttpServletRequest request, throws ServletException, IOException {
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization"); String authHeader = request.getHeader("Authorization");
String token = (authHeader != null && authHeader.startsWith("Bearer ")) String token = (authHeader != null && authHeader.startsWith("Bearer ")) ? authHeader.substring(7) : null;
? authHeader.substring(7)
: null;
if (token == null || token.isBlank()) { if (token == null || token.isBlank()) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
@@ -55,26 +51,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
SecurityContextHolder.clearContext(); SecurityContextHolder.clearContext();
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"message\":\"Invalid or expired token\"}"); response.getWriter().write("{\"message\":\"유효하지 않은 토큰\"}");
return; return;
} }
// 유효하면 인증 객체 세팅 // 유효하면 인증 객체 세팅
String subject = tokenProvider.getSubject(token); // : userId/loginId String accountId = tokenProvider.getSubject(token);
List<String> roles = safeList(tokenProvider.getRoles(token)); UserDetails user = userDetailsService.loadUserByUsername(accountId);
SecurityContextHolder.getContext()
var authorities = roles.stream() .setAuthentication(new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()));
.map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r)
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
var authentication = new UsernamePasswordAuthenticationToken(subject, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
private List<String> safeList(List<String> roles) {
return roles == null ? Collections.emptyList() : roles;
}
} }

View File

@@ -1,7 +1,6 @@
package com.audio.config.security; package com.audio.config.security;
import com.audio.common.TokenProvider; import com.audio.common.TokenProvider;
import com.audio.config.filter.JwtAuthenticationFilter;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
@@ -10,6 +9,7 @@ import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy; 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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
@@ -19,16 +19,21 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@RequiredArgsConstructor @RequiredArgsConstructor
public class SecurityConfig { public class SecurityConfig {
private static final String[] allAuthorizedUrls = {
"/health-check",
"/api/v1/auth/**"
};
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, TokenProvider tokenProvider) throws Exception { public JwtAuthenticationFilter jwtAuthenticationFilter(
TokenProvider tokenProvider,
String[] allAuthorizedUrls = { UserDetailsService userDetailsService
"/health-check", ) {
"/api/v1/auth/**" return new JwtAuthenticationFilter(tokenProvider, userDetailsService, allAuthorizedUrls);
}; }
JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(tokenProvider, allAuthorizedUrls);
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception {
http.csrf(AbstractHttpConfigurer::disable) http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth

View File

@@ -1,12 +1,17 @@
package com.audio.core.auth.api.controller; package com.audio.core.auth.api.controller;
import com.audio.core.auth.api.dto.req.LoginRequest;
import com.audio.core.auth.api.dto.req.SignUpRequest; import com.audio.core.auth.api.dto.req.SignUpRequest;
import com.audio.core.auth.api.dto.res.LoginResponse;
import com.audio.core.auth.api.dto.res.SignUpResponse; import com.audio.core.auth.api.dto.res.SignUpResponse;
import com.audio.core.auth.application.LoginUseCase;
import com.audio.core.auth.application.RegisterUseCase; import com.audio.core.auth.application.RegisterUseCase;
import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand; import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand;
import com.audio.core.auth.application.dto.result.LoginResult;
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult; import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -18,17 +23,14 @@ import org.springframework.web.bind.annotation.RestController;
public class AuthController { public class AuthController {
private final RegisterUseCase registerUseCase; private final RegisterUseCase registerUseCase;
private final LoginUseCase loginUseCase;
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<SignUpResponse> register( public ResponseEntity<SignUpResponse> register(
@RequestBody SignUpRequest request @RequestBody SignUpRequest request
) { ) {
RegisterUseCaseResult result = registerUseCase.execute( RegisterUseCaseResult result = registerUseCase.execute(
RegisterUseCaseCommand.builder() RegisterUseCaseCommand.builder().loginId(request.getLoginId()).password(request.getPassword()).build());
.loginId(request.getLoginId())
.password(request.getPassword())
.build()
);
SignUpResponse response = SignUpResponse.builder() SignUpResponse response = SignUpResponse.builder()
.accessToken(result.getAccessToken()) .accessToken(result.getAccessToken())
@@ -37,4 +39,17 @@ public class AuthController {
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} }
@PostMapping("/login")
public ResponseEntity<LoginResponse> login(
@RequestBody LoginRequest request
) {
LoginResult result = loginUseCase.execute(request.getLoginId(), request.getPassword());
LoginResponse response = LoginResponse.builder()
.accessToken(result.getAccessToken())
.refreshToken(result.getRefreshToken())
.build();
return ResponseEntity.ok(response);
}
} }

View File

@@ -0,0 +1,24 @@
package com.audio.core.auth.api.controller;
import com.audio.core.auth.application.userDetail.User;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequiredArgsConstructor
@RestController
@RequestMapping
public class TestController {
@GetMapping("/test")
public ResponseEntity<Boolean> test(
@AuthenticationPrincipal User user
) {
System.out.println(user.getAccountId());
return ResponseEntity.ok(true);
}
}

View File

@@ -0,0 +1,13 @@
package com.audio.core.auth.api.dto.req;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class LoginRequest {
private String loginId;
private String password;
}

View File

@@ -0,0 +1,18 @@
package com.audio.core.auth.api.dto.res;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
public class LoginResponse {
private String accessToken;
private String refreshToken;
}

View File

@@ -0,0 +1,29 @@
package com.audio.core.auth.application;
import com.audio.common.TokenProvider;
import com.audio.core.auth.application.dto.result.LoginResult;
import com.audio.core.auth.domain.service.AccountService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class LoginUseCase {
private final AccountService accountService;
private final TokenProvider tokenProvider;
public LoginResult execute(String loginId, String password) {
long accountId = accountService.login(loginId, password);
String accessToken = tokenProvider.createUserAccessToken(accountId);
String refreshToken = tokenProvider.createUserRefreshToken(accountId);
return LoginResult.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
}

View File

@@ -5,7 +5,6 @@ import com.audio.core.auth.application.dto.command.RegisterUseCaseCommand;
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult; import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
import com.audio.core.auth.domain.service.AccountService; import com.audio.core.auth.domain.service.AccountService;
import com.audio.core.exception.auth.ExistLoginIdException; import com.audio.core.exception.auth.ExistLoginIdException;
import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -28,8 +27,8 @@ public final class RegisterUseCase {
} }
long accountId = accountService.register(loginId, command.getPassword()); long accountId = accountService.register(loginId, command.getPassword());
String accessToken = tokenProvider.createAccessToken(accountId, List.of("user")); String accessToken = tokenProvider.createUserAccessToken(accountId);
String refreshToken = tokenProvider.createRefreshToken(accountId); String refreshToken = tokenProvider.createUserRefreshToken(accountId);
return RegisterUseCaseResult.builder() return RegisterUseCaseResult.builder()
.accessToken(accessToken) .accessToken(accessToken)

View File

@@ -0,0 +1,16 @@
package com.audio.core.auth.application.dto.result;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public class LoginResult {
private String accessToken;
private String refreshToken;
}

View File

@@ -0,0 +1,66 @@
package com.audio.core.auth.application.userDetail;
import java.io.Serial;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public final class User implements UserDetails {
public static final List<String> roles = List.of("USER");
private static final List<SimpleGrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new).collect(Collectors.toList());
@Serial
private static final long serialVersionUID = 0L;
private final Long accountId;
public User(Long accountId) {
this.accountId = accountId;
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return null;
}
public Long getAccountId() {
return accountId;
}
@Override
public String getUsername() {
return null;
}
@Override
public boolean equals(Object obj) {
if (obj == this) {
return true;
}
if (obj == null || obj.getClass() != this.getClass()) {
return false;
}
var that = (User) obj;
return Objects.equals(this.accountId, that.accountId);
}
@Override
public int hashCode() {
return Objects.hash(accountId);
}
@Override
public String toString() {
return "User[" + "accountId=" + accountId + ']';
}
}

View File

@@ -0,0 +1,14 @@
package com.audio.core.auth.application.userDetail;
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;
@Service
public class UserDetailService implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String accountId) throws UsernameNotFoundException {
return new User(Long.valueOf(accountId));
}
}

View File

@@ -2,6 +2,9 @@ package com.audio.core.auth.domain.service;
import com.audio.core.auth.domain.entity.Account; import com.audio.core.auth.domain.entity.Account;
import com.audio.core.auth.domain.repository.AccountRepository; import com.audio.core.auth.domain.repository.AccountRepository;
import com.audio.core.exception.auth.ExistLoginIdException;
import com.audio.core.exception.auth.InvalidCredentialsException;
import com.audio.core.exception.common.NotExistEntityException;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@@ -41,4 +44,18 @@ public class AccountService {
return accountRepository.findByLoginId(loginId).isPresent(); return accountRepository.findByLoginId(loginId).isPresent();
} }
public long login(String loginId, String password) {
log.info("로그인");
Account account = accountRepository.findByLoginId(loginId)
.orElseThrow(() -> new NotExistEntityException(Account.class.getName()));
if (!passwordEncoder.matches(password, account.getPasswordHash())) {
throw new InvalidCredentialsException();
}
return account.getId();
}
} }

View File

@@ -0,0 +1,19 @@
package com.audio.core.exception.auth;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
public class InvalidCredentialsException extends ErrorResponseException {
public InvalidCredentialsException() {
super(
HttpStatus.UNAUTHORIZED,
ProblemDetail.forStatusAndDetail(
HttpStatus.UNAUTHORIZED,
"아이디 또는 비밀번호가 올바르지 않습니다."
),
null
);
}
}

View File

@@ -0,0 +1,19 @@
package com.audio.core.exception.common;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.ErrorResponseException;
public class NotExistEntityException extends ErrorResponseException {
public NotExistEntityException(String entityName) {
super(
HttpStatus.NOT_FOUND,
ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND,
"존재하지 않는 엔티티: " + entityName
),
null
);
}
}