This commit is contained in:
mindol1004
2024-08-30 17:07:48 +09:00
parent 58495710ec
commit f791097b0a
25 changed files with 452 additions and 53 deletions

View File

@@ -61,6 +61,10 @@
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>

View File

@@ -18,7 +18,7 @@ import lombok.RequiredArgsConstructor;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class AuthController {
public class GenerateTokenApi {
private final AuthService authService;
private final JwtTokenService jwtTokenService;

View File

@@ -0,0 +1,16 @@
package com.spring.domain.user.view;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
@Controller
@RequestMapping("/user")
public class UserView {
@GetMapping("/sign-in")
public String signin() {
return "/views/user/signIn";
}
}

View File

@@ -77,7 +77,7 @@ public class QuartzConfig {
factory.setDataSource(dataSource);
factory.setTransactionManager(transactionManager);
factory.setJobFactory(jobFactory);
factory.setAutoStartup(true);
factory.setAutoStartup(false);
factory.setWaitForJobsToCompleteOnShutdown(true);
return factory;
}

View File

@@ -2,8 +2,11 @@ package com.spring.infra.security.config;
import java.util.List;
import org.springframework.boot.autoconfigure.security.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@@ -18,10 +21,15 @@ import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.spring.infra.security.filter.AuthenticationProcessingFilter;
import com.spring.infra.security.filter.JwtAuthenticationFilter;
import com.spring.infra.security.handler.JwtAccessDeniedHandler;
import com.spring.infra.security.handler.JwtAuthenticationEntryPoint;
import com.spring.infra.security.handler.SecurityAccessDeniedHandler;
import com.spring.infra.security.handler.SecurityAuthenticationEntryPoint;
import com.spring.infra.security.handler.SigninFailureHandler;
import com.spring.infra.security.handler.SigninSuccessHandler;
import com.spring.infra.security.jwt.JwtTokenService;
import com.spring.infra.security.provider.UserAuthenticationProvider;
/**
* 애플리케이션의 보안 설정을 담당하는 구성 클래스입니다.
@@ -36,7 +44,7 @@ import com.spring.infra.security.jwt.JwtTokenService;
@EnableMethodSecurity
public class SecurityConfig {
private static final String[] PERMITTED_URI = {"/favicon.ico", "/api/auth/**", "/signIn", "/h2-console/**"};
private static final String[] PERMITTED_URI = {"/favicon.ico", "/api/auth/**", "/user/sign-in", "/h2-console/**"};
/**
* Spring Security의 필터 체인을 구성합니다.
@@ -51,9 +59,10 @@ public class SecurityConfig {
SecurityFilterChain securityFilterChain(
HttpSecurity http,
JwtTokenService tokenService,
JwtAuthenticationEntryPoint authenticationEntryPoint,
JwtAccessDeniedHandler accessDeniedHandler) throws Exception
{
SecurityAuthenticationEntryPoint authenticationEntryPoint,
SecurityAccessDeniedHandler accessDeniedHandler,
AuthenticationProcessingFilter authenticationProcessingFilter
) throws Exception {
http
.headers(headers -> headers.frameOptions(FrameOptionsConfig::sameOrigin))
.csrf(CsrfConfigurer::disable)
@@ -64,12 +73,19 @@ public class SecurityConfig {
.anyRequest().authenticated()
)
.logout(logout -> logout
.logoutSuccessUrl("/signIn")
.logoutSuccessUrl("/user/sign-in")
.invalidateHttpSession(true))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.addFilterBefore(new JwtAuthenticationFilter(tokenService, List.of(PERMITTED_URI)), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(
authenticationProcessingFilter,
UsernamePasswordAuthenticationFilter.class
)
.addFilterAfter(
new JwtAuthenticationFilter(tokenService, List.of(PERMITTED_URI)),
AuthenticationProcessingFilter.class
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
@@ -84,7 +100,8 @@ public class SecurityConfig {
*/
@Bean
WebSecurityCustomizer ignoringCustomizer() {
return web -> web.ignoring().antMatchers("/h2-console/**");
return web -> web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
/**
@@ -97,4 +114,23 @@ public class SecurityConfig {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
AuthenticationManager authenticationManager(UserAuthenticationProvider provider) {
return new ProviderManager(provider);
}
@Bean
AuthenticationProcessingFilter authenticationProcessingFilter(
ObjectMapper objectMapper,
AuthenticationManager authenticationManager,
SigninSuccessHandler signinSuccessHandler,
SigninFailureHandler signinFailureHandler
) {
var filter = new AuthenticationProcessingFilter(objectMapper);
filter.setAuthenticationManager(authenticationManager);
filter.setAuthenticationSuccessHandler(signinSuccessHandler);
filter.setAuthenticationFailureHandler(signinFailureHandler);
return filter;
}
}

View File

@@ -0,0 +1,14 @@
package com.spring.infra.security.dto;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class SignInRequest {
private String username;
private String password;
}

View File

@@ -0,0 +1,60 @@
package com.spring.infra.security.filter;
import java.io.IOException;
import java.util.Objects;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.spring.infra.security.dto.SignInRequest;
public class AuthenticationProcessingFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/sign-in";
private static final String HTTP_METHOD = "POST";
private static final AntPathRequestMatcher DEFAULT_LOGIN_PATH_REQUEST_MATCHER =
new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD);
private final ObjectMapper objectMapper;
public AuthenticationProcessingFilter(ObjectMapper objectMapper) {
super(DEFAULT_LOGIN_PATH_REQUEST_MATCHER);
this.objectMapper = objectMapper;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException, IOException, ServletException {
if (!isValidRequestType(request)) {
throw new IllegalStateException("request is not supported. check request method and content-type");
}
var signInRequest = objectMapper.readValue(request.getReader(), SignInRequest.class);
if (!isValidRequest(signInRequest)) {
throw new IllegalArgumentException("Ussername & Password are not empty!!");
}
var token = new UsernamePasswordAuthenticationToken(signInRequest.getUsername(), signInRequest.getPassword());
return this.getAuthenticationManager().authenticate(token);
}
private boolean isValidRequestType(HttpServletRequest request) {
return Objects.equals(request.getMethod(), HttpMethod.POST.name()) &&
Objects.equals(request.getContentType(), MediaType.APPLICATION_JSON_VALUE);
}
private boolean isValidRequest(SignInRequest signInRequest) {
return StringUtils.hasText(signInRequest.getUsername()) ||
StringUtils.hasText(signInRequest.getPassword());
}
}

View File

@@ -20,7 +20,7 @@ import org.springframework.stereotype.Component;
* @version 1.0
*/
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
public class SecurityAccessDeniedHandler implements AccessDeniedHandler {
/**
* 접근 거부 상황을 처리합니다.

View File

@@ -20,7 +20,7 @@ import org.springframework.stereotype.Component;
* @version 1.0
*/
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
/**
* 인증되지 않은 접근을 처리합니다.

View File

@@ -0,0 +1,25 @@
package com.spring.infra.security.handler;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.stereotype.Component;
@Component
public class SigninFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException exception
) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); //401 인증 실패
}
}

View File

@@ -0,0 +1,48 @@
package com.spring.infra.security.handler;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;
import com.spring.infra.security.jwt.JwtTokenService;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class SigninSuccessHandler implements AuthenticationSuccessHandler {
private final JwtTokenService jwtTokenService;
private RequestCache requestCache = new HttpSessionRequestCache();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
@Override
public void onAuthenticationSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication
) throws IOException, ServletException {
jwtTokenService.generateAccessToken(response, authentication);
jwtTokenService.generateRefreshToken(response, authentication);
SavedRequest savedRequest = requestCache.getRequest(request, response);
if (savedRequest != null) { // 접근 권한 없는 경로 접근해서 스프링 시큐리티가 인터셉트해서 로그인폼으로 이동 후 로그인 성공한 경우
redirectStrategy.sendRedirect(request, response, savedRequest.getRedirectUrl());
} else { // 로그인 버튼 눌러서 로그인한 경우 기존에 있던 페이지로 리다이렉트
String prevPage = String.valueOf(request.getSession().getAttribute("prevPage"));
redirectStrategy.sendRedirect(request, response, prevPage);
}
}
}

View File

@@ -0,0 +1,43 @@
package com.spring.infra.security.provider;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import com.spring.infra.security.service.UserPrincipalService;
import lombok.RequiredArgsConstructor;
@Component
@RequiredArgsConstructor
public class UserAuthenticationProvider implements AuthenticationProvider {
private final PasswordEncoder passwordEncoder;
private final UserPrincipalService userPrincipalService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String loginId = authentication.getName();
String password = String.valueOf(authentication.getCredentials());
UserDetails user = userPrincipalService.loadUserByUsername(loginId);
if (isNotMatches(password, user.getPassword())) {
throw new BadCredentialsException(loginId);
}
return new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
private boolean isNotMatches(String password, String encodePassword) {
return !passwordEncoder.matches(password, encodePassword);
}
}

View File

@@ -73,6 +73,14 @@ spring:
threadCount: 10
threadPriority: 5
thymeleaf:
cache: false
check-template-location: false
enabled: true
prefix: classpath:/templates
suffix: .html
view-names: /views/*
h2:
console: # H2 DB를 웹에서 관리할 수 있는 기능
enabled: true # H2 Console 사용 여부

View File

@@ -1,40 +0,0 @@
spring:
quartz:
scheduler:
instanceName: batch-quartz
instance-id: SYS_PROP
name: BatchQuartzScheduler
org:
quartz:
jobStore:
tablePrefix: QRTZ_
isClustered: true
misfireThreshold: 2000
clusterCheckinInterval: 1000
class: org.quartz.impl.jdbcjobstore.JobStoreTX
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate #org.quartz.impl.jdbcjobstore.oracle.OracleDelegate
acquireTriggersWithinLock: true
scheduler:
instance-id:
instanceName:
rmi:
export: false
proxy: false
batchTriggerAcquisitionMaxCount: 20
idleWaitTime: 1000
skipUpdateCheck: true
threadPool:
class: org.quartz.simpl.SimpleThreadPool
threadCount: 10
threadPriority: 5
threadsInheritContextClassLoaderOfInitializingThread: true
threadNamePrefix: BatchQuartz
# dataSource:
# nxcus:
# driver: org.h2.Driver #oracle.jdbc.driver.OracleDriver
# URL: 'jdbc:h2:mem:test' #jdbc:oracle:thin:@polarbear:1521:dev
# user: mindol1004
# password: 1111
# maxConnections: 5
# validationQuery: select 0 from dual

View File

@@ -0,0 +1,87 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: linear-gradient(to right, #f0f0f5, #e9ecef);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
}
.container {
background-color: white;
padding: 30px;
border-radius: 15px;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
width: 350px;
text-align: center;
}
.icon {
margin-bottom: 20px;
}
.icon img {
width: 50px; /* 아이콘 크기 조정 */
}
h1 {
color: #333;
margin-bottom: 20px;
}
.input-group {
margin-bottom: 15px;
}
.input-icon {
display: flex;
align-items: center;
border: 1px solid #ccc;
border-radius: 5px;
padding: 10px;
}
.input-icon img {
margin-right: 10px; /* 아이콘과 인풋 간격 조정 */
width: 20px; /* 아이콘 크기 조정 */
height: 20px; /* 아이콘 크기 조정 */
}
input {
width: 100%;
border: none;
outline: none;
font-size: 16px;
}
input::placeholder {
color: #aaa; /* 플레이스홀더 색상 */
}
.remember-me {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 15px;
}
.remember-me label {
margin-left: 5px;
}
button {
width: 100%;
padding: 12px;
background-color: #007aff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
button:hover {
background-color: #005bb5;
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,43 @@
// Axios 인스턴스 생성
const axiosInstance = axios.create({
baseURL: 'http://localhost:8081', // 기본 URL 설정
timeout: 10000, // 요청 타임아웃 설정 (10초)
headers: {
'Content-Type': 'application/json',
// 필요한 경우 추가 헤더 설정
}
});
// 요청 인터셉터 (필요한 경우)
axiosInstance.interceptors.request.use(
config => {
// 요청 전에 수행할 작업 (예: 토큰 추가)
const token = localStorage.getItem('token'); // 예시: 로컬 스토리지에서 토큰 가져오기
if (token) {
config.headers['Authorization'] = `Bearer ${token}`;
}
return config;
},
error => {
return Promise.reject(error);
}
);
// 응답 인터셉터 (필요한 경우)
axiosInstance.interceptors.response.use(
response => {
return response;
},
error => {
console.log(error.response);
// 오류 처리 (예: 401 Unauthorized 처리)
if (error.response && error.response.status === 401) {
// 로그아웃 처리 또는 리다이렉트
console.log("111111111111111");
console.log(error.response);
}
return Promise.reject(error);
}
);
export default axiosInstance;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
import axiosInstance from '../common/axiosInstance.js';
const login = async (username, password) => {
try {
const response = await axiosInstance.post('/sign-in', {
username,
password
});
console.log('로그인 성공:', response.data);
} catch (error) {
console.error('로그인 실패:', error);
}
};
document.getElementById('signinForm').addEventListener('submit', function(event) {
event.preventDefault(); // 기본 폼 제출 방지
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
login(username, password);
});

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="ko" xml:lang="ko">
<head>
<meta charset="UTF-8">
<title>로그인 페이지</title>
<link rel="stylesheet" href="/css/style.css">
<script src="/js/lib/axios/axios.min.js"></script>
<script type="module" src="/js/user/signIn.js" defer></script>
</head>
<body>
<div class="container">
<div class="icon">
<img src="/images/user.png" alt="User Icon">
</div>
<h1>로그인</h1>
<form id="signinForm" method="post">
<div class="input-group">
<div class="input-icon">
<img src="/images/user-id.png" alt="User Icon">
<input type="text" id="username" name="username" placeholder="아이디">
</div>
</div>
<div class="input-group">
<div class="input-icon">
<img src="/images/user-lock.png" alt="Password Icon">
<input type="password" id="password" name="password" placeholder="비밀번호">
</div>
</div>
<button type="submit">로그인</button>
</form>
</div>
</body>
</html>