diff --git a/src/main/java/com/audio/common/TokenProvider.java b/src/main/java/com/audio/common/TokenProvider.java index 7d84fa0..ce32fb8 100644 --- a/src/main/java/com/audio/common/TokenProvider.java +++ b/src/main/java/com/audio/common/TokenProvider.java @@ -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 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); } } \ No newline at end of file diff --git a/src/main/java/com/audio/config/filter/JwtAuthenticationFilter.java b/src/main/java/com/audio/config/security/JwtAuthenticationFilter.java similarity index 58% rename from src/main/java/com/audio/config/filter/JwtAuthenticationFilter.java rename to src/main/java/com/audio/config/security/JwtAuthenticationFilter.java index 13c542f..e4b1d3c 100644 --- a/src/main/java/com/audio/config/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/audio/config/security/JwtAuthenticationFilter.java @@ -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 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 safeList(List roles) { - return roles == null ? Collections.emptyList() : roles; - } } diff --git a/src/main/java/com/audio/config/security/SecurityConfig.java b/src/main/java/com/audio/config/security/SecurityConfig.java index 2ed67b5..489e182 100644 --- a/src/main/java/com/audio/config/security/SecurityConfig.java +++ b/src/main/java/com/audio/config/security/SecurityConfig.java @@ -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 { + private static final String[] allAuthorizedUrls = { + "/health-check", + "/api/v1/auth/**" + }; + @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, TokenProvider tokenProvider) throws Exception { - - String[] allAuthorizedUrls = { - "/health-check", - "/api/v1/auth/**" - }; - - JwtAuthenticationFilter jwtFilter = new JwtAuthenticationFilter(tokenProvider, allAuthorizedUrls); + 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 diff --git a/src/main/java/com/audio/core/auth/api/controller/AuthController.java b/src/main/java/com/audio/core/auth/api/controller/AuthController.java index 1d77261..f78b375 100644 --- a/src/main/java/com/audio/core/auth/api/controller/AuthController.java +++ b/src/main/java/com/audio/core/auth/api/controller/AuthController.java @@ -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 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 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); + } } diff --git a/src/main/java/com/audio/core/auth/api/controller/TestController.java b/src/main/java/com/audio/core/auth/api/controller/TestController.java new file mode 100644 index 0000000..571f5d1 --- /dev/null +++ b/src/main/java/com/audio/core/auth/api/controller/TestController.java @@ -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 test( + @AuthenticationPrincipal User user + ) { + System.out.println(user.getAccountId()); + + return ResponseEntity.ok(true); + } +} diff --git a/src/main/java/com/audio/core/auth/api/dto/req/LoginRequest.java b/src/main/java/com/audio/core/auth/api/dto/req/LoginRequest.java new file mode 100644 index 0000000..fcbeb28 --- /dev/null +++ b/src/main/java/com/audio/core/auth/api/dto/req/LoginRequest.java @@ -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; +} diff --git a/src/main/java/com/audio/core/auth/api/dto/res/LoginResponse.java b/src/main/java/com/audio/core/auth/api/dto/res/LoginResponse.java new file mode 100644 index 0000000..d32f5f3 --- /dev/null +++ b/src/main/java/com/audio/core/auth/api/dto/res/LoginResponse.java @@ -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; +} diff --git a/src/main/java/com/audio/core/auth/application/LoginUseCase.java b/src/main/java/com/audio/core/auth/application/LoginUseCase.java new file mode 100644 index 0000000..f1facbf --- /dev/null +++ b/src/main/java/com/audio/core/auth/application/LoginUseCase.java @@ -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(); + } +} diff --git a/src/main/java/com/audio/core/auth/application/RegisterUseCase.java b/src/main/java/com/audio/core/auth/application/RegisterUseCase.java index bd18221..eef737d 100644 --- a/src/main/java/com/audio/core/auth/application/RegisterUseCase.java +++ b/src/main/java/com/audio/core/auth/application/RegisterUseCase.java @@ -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) diff --git a/src/main/java/com/audio/core/auth/application/dto/result/LoginResult.java b/src/main/java/com/audio/core/auth/application/dto/result/LoginResult.java new file mode 100644 index 0000000..d7d8b68 --- /dev/null +++ b/src/main/java/com/audio/core/auth/application/dto/result/LoginResult.java @@ -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; +} diff --git a/src/main/java/com/audio/core/auth/application/userDetail/User.java b/src/main/java/com/audio/core/auth/application/userDetail/User.java new file mode 100644 index 0000000..1e31f01 --- /dev/null +++ b/src/main/java/com/audio/core/auth/application/userDetail/User.java @@ -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 roles = List.of("USER"); + + private static final List 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 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 + ']'; + } + +} \ No newline at end of file diff --git a/src/main/java/com/audio/core/auth/application/userDetail/UserDetailService.java b/src/main/java/com/audio/core/auth/application/userDetail/UserDetailService.java new file mode 100644 index 0000000..4d119e2 --- /dev/null +++ b/src/main/java/com/audio/core/auth/application/userDetail/UserDetailService.java @@ -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)); + } +} diff --git a/src/main/java/com/audio/core/auth/domain/service/AccountService.java b/src/main/java/com/audio/core/auth/domain/service/AccountService.java index 8078e6f..0d9046f 100644 --- a/src/main/java/com/audio/core/auth/domain/service/AccountService.java +++ b/src/main/java/com/audio/core/auth/domain/service/AccountService.java @@ -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(); + + } } diff --git a/src/main/java/com/audio/core/exception/auth/InvalidCredentialsException.java b/src/main/java/com/audio/core/exception/auth/InvalidCredentialsException.java new file mode 100644 index 0000000..caef08c --- /dev/null +++ b/src/main/java/com/audio/core/exception/auth/InvalidCredentialsException.java @@ -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 + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/audio/core/exception/common/NotExistEntityException.java b/src/main/java/com/audio/core/exception/common/NotExistEntityException.java new file mode 100644 index 0000000..8b91bfe --- /dev/null +++ b/src/main/java/com/audio/core/exception/common/NotExistEntityException.java @@ -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 + ); + } +} \ No newline at end of file