feat: 로그인
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
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.JwtException;
|
||||
import io.jsonwebtoken.JwtParser;
|
||||
@@ -24,7 +24,7 @@ public class TokenProvider {
|
||||
private final Duration refreshToken;
|
||||
|
||||
/** Access Token 생성 */
|
||||
public String createAccessToken(Long accountId, List<String> roles) {
|
||||
public String createUserAccessToken(Long accountId) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plus(accessToken);
|
||||
|
||||
@@ -34,13 +34,13 @@ public class TokenProvider {
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(exp))
|
||||
.claim("typ", "access")
|
||||
.claim("roles", roles)
|
||||
.claim("roles", User.roles)
|
||||
.signWith(key, SignatureAlgorithm.HS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
/** Refresh Token 생성 */
|
||||
public String createRefreshToken(Long accountId) {
|
||||
public String createUserRefreshToken(Long accountId) {
|
||||
Instant now = Instant.now();
|
||||
Instant exp = now.plus(refreshToken);
|
||||
|
||||
@@ -67,9 +67,17 @@ public class TokenProvider {
|
||||
/** Access 토큰인지(typ=access)까지 확인 */
|
||||
public boolean validateAccessToken(String token) {
|
||||
try {
|
||||
log.info("액세스 토큰 검증");
|
||||
log.info(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));
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
log.error(e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -102,7 +110,7 @@ public class TokenProvider {
|
||||
}
|
||||
|
||||
private String stripBearer(String token) {
|
||||
if (token == null) return null;
|
||||
return token.startsWith("Bearer ") ? token.substring(7) : token;
|
||||
if (token == null || !token.startsWith("Bearer ")) return null;
|
||||
return token.substring(7);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.audio.config.filter;
|
||||
package com.audio.config.security;
|
||||
|
||||
import com.audio.common.TokenProvider;
|
||||
import jakarta.servlet.FilterChain;
|
||||
@@ -8,19 +8,18 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.MediaType;
|
||||
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.userdetails.UserDetails;
|
||||
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||
import org.springframework.util.AntPathMatcher;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@RequiredArgsConstructor
|
||||
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
private final TokenProvider tokenProvider;
|
||||
private final UserDetailsService userDetailsService;
|
||||
private final AntPathMatcher pathMatcher = new AntPathMatcher();
|
||||
private final String[] permitAllPatterns;
|
||||
|
||||
@@ -28,22 +27,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
String path = request.getRequestURI();
|
||||
for (String pattern : permitAllPatterns) {
|
||||
if (pathMatcher.match(pattern, path)) return true;
|
||||
if (pathMatcher.match(pattern, path)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
HttpServletRequest request,
|
||||
HttpServletResponse response,
|
||||
FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
|
||||
String authHeader = request.getHeader("Authorization");
|
||||
String token = (authHeader != null && authHeader.startsWith("Bearer "))
|
||||
? authHeader.substring(7)
|
||||
: null;
|
||||
String token = (authHeader != null && authHeader.startsWith("Bearer ")) ? authHeader.substring(7) : null;
|
||||
|
||||
if (token == null || token.isBlank()) {
|
||||
filterChain.doFilter(request, response);
|
||||
@@ -55,26 +51,16 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
SecurityContextHolder.clearContext();
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.getWriter().write("{\"message\":\"Invalid or expired token\"}");
|
||||
response.getWriter().write("{\"message\":\"유효하지 않은 토큰\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 유효하면 인증 객체 세팅
|
||||
String subject = tokenProvider.getSubject(token); // 예: userId/loginId
|
||||
List<String> roles = safeList(tokenProvider.getRoles(token));
|
||||
|
||||
var authorities = roles.stream()
|
||||
.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);
|
||||
String accountId = tokenProvider.getSubject(token);
|
||||
UserDetails user = userDetailsService.loadUserByUsername(accountId);
|
||||
SecurityContextHolder.getContext()
|
||||
.setAuthentication(new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()));
|
||||
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private List<String> safeList(List<String> roles) {
|
||||
return roles == null ? Collections.emptyList() : roles;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.audio.config.security;
|
||||
|
||||
import com.audio.common.TokenProvider;
|
||||
import com.audio.config.filter.JwtAuthenticationFilter;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.configurers.AbstractHttpConfigurer;
|
||||
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.password.PasswordEncoder;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
@@ -19,16 +19,21 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
|
||||
@RequiredArgsConstructor
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http, TokenProvider tokenProvider) throws Exception {
|
||||
|
||||
String[] allAuthorizedUrls = {
|
||||
private static final String[] allAuthorizedUrls = {
|
||||
"/health-check",
|
||||
"/api/v1/auth/**"
|
||||
};
|
||||
|
||||
JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(tokenProvider, allAuthorizedUrls);
|
||||
@Bean
|
||||
public JwtAuthenticationFilter jwtAuthenticationFilter(
|
||||
TokenProvider tokenProvider,
|
||||
UserDetailsService userDetailsService
|
||||
) {
|
||||
return new JwtAuthenticationFilter(tokenProvider, userDetailsService, allAuthorizedUrls);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter) throws Exception {
|
||||
http.csrf(AbstractHttpConfigurer::disable)
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
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.res.LoginResponse;
|
||||
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.dto.command.RegisterUseCaseCommand;
|
||||
import com.audio.core.auth.application.dto.result.LoginResult;
|
||||
import com.audio.core.auth.application.dto.result.RegisterUseCaseResult;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
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.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -18,17 +23,14 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class AuthController {
|
||||
|
||||
private final RegisterUseCase registerUseCase;
|
||||
private final LoginUseCase loginUseCase;
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<SignUpResponse> register(
|
||||
@RequestBody SignUpRequest request
|
||||
) {
|
||||
RegisterUseCaseResult result = registerUseCase.execute(
|
||||
RegisterUseCaseCommand.builder()
|
||||
.loginId(request.getLoginId())
|
||||
.password(request.getPassword())
|
||||
.build()
|
||||
);
|
||||
RegisterUseCaseCommand.builder().loginId(request.getLoginId()).password(request.getPassword()).build());
|
||||
|
||||
SignUpResponse response = SignUpResponse.builder()
|
||||
.accessToken(result.getAccessToken())
|
||||
@@ -37,4 +39,17 @@ public class AuthController {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.domain.service.AccountService;
|
||||
import com.audio.core.exception.auth.ExistLoginIdException;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
@@ -28,8 +27,8 @@ public final class RegisterUseCase {
|
||||
}
|
||||
|
||||
long accountId = accountService.register(loginId, command.getPassword());
|
||||
String accessToken = tokenProvider.createAccessToken(accountId, List.of("user"));
|
||||
String refreshToken = tokenProvider.createRefreshToken(accountId);
|
||||
String accessToken = tokenProvider.createUserAccessToken(accountId);
|
||||
String refreshToken = tokenProvider.createUserRefreshToken(accountId);
|
||||
|
||||
return RegisterUseCaseResult.builder()
|
||||
.accessToken(accessToken)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 + ']';
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
@@ -41,4 +44,18 @@ public class AccountService {
|
||||
|
||||
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();
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user