admin: form 기반 로그인 -> json http 기반 로그인으로 변경

This commit is contained in:
geonhos 2024-05-13 15:21:49 +09:00
parent e368414006
commit b81ab51a11
20 changed files with 299 additions and 96 deletions

View File

@ -1,18 +1,24 @@
package com.bpgroup.poc.admin.security; package com.bpgroup.poc.admin.security;
import com.bpgroup.poc.admin.common.FormatHelper; import com.bpgroup.poc.admin.security.authentication.LoginService;
import com.bpgroup.poc.admin.security.authentication.AuthenticationFailException; import com.bpgroup.poc.admin.security.authentication.CustomAuthenticationFilter;
import com.bpgroup.poc.admin.security.authentication.CustomAuthenticationProvider;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.config.Customizer; import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter; import org.springframework.security.web.header.writers.XXssProtectionHeaderWriter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import java.util.Objects; import java.util.ArrayList;
import java.util.List;
@Configuration @Configuration
@RequiredArgsConstructor @RequiredArgsConstructor
@ -22,6 +28,8 @@ public class SecurityConfig {
private static final String LOGOUT_PATH = "/logout"; private static final String LOGOUT_PATH = "/logout";
private static final String ERROR_PATH = "/error"; private static final String ERROR_PATH = "/error";
private final LoginService loginService;
@Bean @Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
// 보안 기본 설정 // 보안 기본 설정
@ -39,19 +47,8 @@ public class SecurityConfig {
.requestMatchers(LOGIN_PATH, LOGOUT_PATH, ERROR_PATH).permitAll() .requestMatchers(LOGIN_PATH, LOGOUT_PATH, ERROR_PATH).permitAll()
.anyRequest().authenticated()); .anyRequest().authenticated());
http.formLogin(c -> c http.formLogin(AbstractHttpConfigurer::disable); // Form 로그인이 아닌 Json 로그인으로 분리
.loginPage(LOGIN_PATH) http.addFilterBefore(authenticationGenerateFilter(), UsernamePasswordAuthenticationFilter.class); // 로그인 관련 Filter 설정
.loginProcessingUrl(LOGIN_PATH)
.usernameParameter("loginId")
.passwordParameter("password")
.defaultSuccessUrl("/main")
.failureHandler((req, res, ex) -> {
if (Objects.requireNonNull(ex) instanceof AuthenticationFailException authenticationFailException) {
res.sendRedirect(FormatHelper.format(LOGIN_PATH + "?error={}", authenticationFailException.getReason()));
} else {
res.sendRedirect(ERROR_PATH);
}
}));
http.logout(c -> c http.logout(c -> c
.logoutRequestMatcher(new AntPathRequestMatcher(LOGOUT_PATH, "GET")) .logoutRequestMatcher(new AntPathRequestMatcher(LOGOUT_PATH, "GET"))
@ -61,12 +58,23 @@ public class SecurityConfig {
return http.build(); return http.build();
} }
/**
* Bcrypt Version, Bcrypt Strength, Salt String 설정은 생성자를 이용하여 설정 가능
*/
@Bean @Bean
public BCryptPasswordEncoder passwordEncoder() { public CustomAuthenticationProvider customAuthenticationProvider() {
return new BCryptPasswordEncoder(); return new CustomAuthenticationProvider(loginService);
}
@Bean
public AuthenticationManager authenticationManager() {
List<AuthenticationProvider> providers = new ArrayList<>();
providers.add(customAuthenticationProvider());
return new ProviderManager(providers);
}
@Bean
public CustomAuthenticationFilter authenticationGenerateFilter() {
CustomAuthenticationFilter filter = new CustomAuthenticationFilter();
filter.setAuthenticationManager(authenticationManager());
return filter;
} }
} }

View File

@ -0,0 +1,20 @@
package com.bpgroup.poc.admin.security;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
/**
* SecurityConfig 있을 경우 Service 주입 순환 참조가 발생하여 별로 파일로 분리
*/
@Configuration
public class SecurityEncryptConfig {
/**
* Bcrypt Version, Bcrypt Strength, Salt String 설정은 생성자를 이용하여 설정 가능
*/
@Bean
public BCryptPasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@ -1,6 +1,5 @@
package com.bpgroup.poc.admin.security.authentication; package com.bpgroup.poc.admin.security.authentication;
import com.bpgroup.poc.admin.app.login.LoginResult;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;
import lombok.ToString; import lombok.ToString;

View File

@ -1,24 +0,0 @@
package com.bpgroup.poc.admin.security.authentication;
import com.bpgroup.poc.admin.app.login.exception.AdministratorNotFoundException;
import com.bpgroup.poc.admin.app.login.exception.DoNotHaveAnyMenuException;
import com.bpgroup.poc.admin.app.login.exception.InvalidPasswordException;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public enum AuthenticationFailReason {
WRONG_LOGIN_ID,
WRONG_PASSWORD,
HAVE_NO_MENU,
INTERNAL_ERROR;
public static AuthenticationFailReason from(Exception e) {
if (e instanceof AdministratorNotFoundException || e instanceof InvalidPasswordException) {
return WRONG_LOGIN_ID;
} else if (e instanceof DoNotHaveAnyMenuException) {
return HAVE_NO_MENU;
} else {
return INTERNAL_ERROR;
}
}
}

View File

@ -0,0 +1,36 @@
package com.bpgroup.poc.admin.security.authentication;
import com.bpgroup.poc.admin.security.authentication.exception.AuthenticationFailException;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String failMessage = "시스템 오류가 발생했습니다.";
if (Objects.requireNonNull(exception) instanceof AuthenticationFailException authenticationFailException) {
failMessage = authenticationFailException.getReason().getMessage();
}
String jsonResponse = new ObjectMapper().writeValueAsString(
LoginResponse.fail(
"9999",
failMessage
)
);
response.getWriter().write(jsonResponse);
}
}

View File

@ -0,0 +1,67 @@
package com.bpgroup.poc.admin.security.authentication;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationServiceException;
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.StreamUtils;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class CustomAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private static final String DEFAULT_LOGIN_REQUEST_URL = "/login";
private static final String HTTP_METHOD = "POST";
private static final String CONTENT_TYPE = "application/json";
private boolean postOnly = true;
public CustomAuthenticationFilter() {
super(new AntPathRequestMatcher(DEFAULT_LOGIN_REQUEST_URL, HTTP_METHOD)); // 위에서 설정한 /oauth2/login/* 요청에, GET으로 요청을 처리하기 위해 설정한다.
setAuthenticationSuccessHandler(new CustomAuthenticationSuccessHandler());
setAuthenticationFailureHandler(new CustomAuthenticationFailureHandler());
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
// 요청에 대한 유효성 검사
isValidated(request);
LoginRequest loginRequest = new ObjectMapper().readValue(
StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8),
LoginRequest.class
);
String username = loginRequest.getUsername();
String password = loginRequest.getPassword();
if (username == null || password == null) {
throw new AuthenticationServiceException("DATA IS MISS");
}
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
setDetails(request, authenticationToken);
return this.getAuthenticationManager().authenticate(authenticationToken);
}
private void isValidated(HttpServletRequest request) {
if (this.postOnly && !request.getMethod().equals(HTTP_METHOD)) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
if (request.getContentType() == null || !request.getContentType().equals(CONTENT_TYPE)) {
throw new AuthenticationServiceException("Authentication Content-Type not supported: " + request.getContentType());
}
}
protected void setDetails(HttpServletRequest request, UsernamePasswordAuthenticationToken authRequest) {
authRequest.setDetails(this.authenticationDetailsSource.buildDetails(request));
}
}

View File

@ -1,27 +1,28 @@
package com.bpgroup.poc.admin.security.authentication; package com.bpgroup.poc.admin.security.authentication;
import com.bpgroup.poc.admin.app.login.LoginResult; import com.bpgroup.poc.admin.security.authentication.exception.AuthenticationFailException;
import com.bpgroup.poc.admin.app.login.LoginService; import com.bpgroup.poc.admin.security.authentication.exception.AuthenticationFailReason;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationException;
import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional;
@Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider { public class CustomAuthenticationProvider implements AuthenticationProvider {
private final LoginService loginService; private final LoginService loginService;
@Transactional
@Override @Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException { public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String loginId = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
try { try {
LoginResult loginResult = loginService.login(loginId, password); String username = (String) authentication.getPrincipal();
String password = (String) authentication.getCredentials();
LoginResult loginResult = loginService.login(username, password);
return buildAuthenticationToken(loginResult); return buildAuthenticationToken(loginResult);
} catch (Exception e) { } catch (Exception e) {
throw new AuthenticationFailException("로그인에 실패하였습니다.", AuthenticationFailReason.from(e)); throw new AuthenticationFailException("로그인에 실패하였습니다.", AuthenticationFailReason.from(e));

View File

@ -0,0 +1,35 @@
package com.bpgroup.poc.admin.security.authentication;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authentication) throws IOException, ServletException {
AuthenticationSuccessHandler.super.onAuthenticationSuccess(request, response, chain, authentication);
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_OK);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
String jsonResponse = new ObjectMapper().writeValueAsString(
LoginResponse.success(
"token"
)
);
response.getWriter().write(jsonResponse);
}
}

View File

@ -0,0 +1,12 @@
package com.bpgroup.poc.admin.security.authentication;
import lombok.Data;
/**
* 로그인 요청 Request
*/
@Data
public class LoginRequest {
private String username;
private String password;
}

View File

@ -0,0 +1,29 @@
package com.bpgroup.poc.admin.security.authentication;
import lombok.Getter;
import lombok.ToString;
@Getter
@ToString
public class LoginResponse {
private String resultCode;
private String resultMessage;
private String token;
public static LoginResponse success(String token) {
LoginResponse response = new LoginResponse();
response.resultCode = "0000";
response.resultMessage = "Success";
response.token = token;
return response;
}
public static LoginResponse fail(String resultCode, String resultMessage) {
LoginResponse response = new LoginResponse();
response.resultCode = resultCode;
response.resultMessage = resultMessage;
return response;
}
}

View File

@ -1,4 +1,4 @@
package com.bpgroup.poc.admin.app.login; package com.bpgroup.poc.admin.security.authentication;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
import lombok.Getter; import lombok.Getter;

View File

@ -1,8 +1,8 @@
package com.bpgroup.poc.admin.app.login; package com.bpgroup.poc.admin.security.authentication;
import com.bpgroup.poc.admin.app.login.exception.AdministratorNotFoundException; import com.bpgroup.poc.admin.security.authentication.exception.AdministratorNotFoundException;
import com.bpgroup.poc.admin.app.login.exception.DoNotHaveAnyMenuException; import com.bpgroup.poc.admin.security.authentication.exception.DoNotHaveAnyMenuException;
import com.bpgroup.poc.admin.app.login.exception.InvalidPasswordException; import com.bpgroup.poc.admin.security.authentication.exception.InvalidPasswordException;
import com.bpgroup.poc.admin.domain.admin.entity.Administrator; import com.bpgroup.poc.admin.domain.admin.entity.Administrator;
import com.bpgroup.poc.admin.domain.admin.entity.AdministratorRepository; import com.bpgroup.poc.admin.domain.admin.entity.AdministratorRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -17,21 +17,20 @@ import java.util.stream.Collectors;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@Transactional
public class LoginService { public class LoginService {
private final AdministratorRepository loginRepository; private final AdministratorRepository administratorRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
public LoginResult login(String loginId, String pwd) throws AdministratorNotFoundException, InvalidPasswordException, DoNotHaveAnyMenuException { @Transactional
public LoginResult login(String username, String password) throws AdministratorNotFoundException, InvalidPasswordException, DoNotHaveAnyMenuException {
Optional<Administrator> administrator = loginRepository.findByLoginId(loginId); Optional<Administrator> administrator = administratorRepository.findByLoginId(username);
if (administrator.isEmpty()) { if (administrator.isEmpty()) {
throw new AdministratorNotFoundException(loginId); throw new AdministratorNotFoundException(username);
} }
if (!passwordEncoder.matches(pwd, administrator.get().getPassword())) { if (!passwordEncoder.matches(password, administrator.get().getPassword())) {
throw new InvalidPasswordException(loginId); throw new InvalidPasswordException(username);
} }
return LoginResult.of( return LoginResult.of(
@ -43,7 +42,7 @@ public class LoginService {
); );
} }
private static LinkedHashSet<LoginResult.MenuInfo> getMenus(Administrator administrator) throws DoNotHaveAnyMenuException { private LinkedHashSet<LoginResult.MenuInfo> getMenus(Administrator administrator) throws DoNotHaveAnyMenuException {
try { try {
return administrator.getAdministratorRole().getRole().getRoleMenus().stream() return administrator.getAdministratorRole().getRole().getRoleMenus().stream()
.map(roleMenu -> LoginResult.MenuInfo.of( .map(roleMenu -> LoginResult.MenuInfo.of(
@ -65,4 +64,5 @@ public class LoginService {
} }
} }
} }

View File

@ -1,4 +1,4 @@
package com.bpgroup.poc.admin.app.login.exception; package com.bpgroup.poc.admin.security.authentication.exception;
public class AdministratorNotFoundException extends Exception { public class AdministratorNotFoundException extends Exception {
public AdministratorNotFoundException(String message) { public AdministratorNotFoundException(String message) {

View File

@ -1,4 +1,4 @@
package com.bpgroup.poc.admin.security.authentication; package com.bpgroup.poc.admin.security.authentication.exception;
import lombok.Getter; import lombok.Getter;
import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.AuthenticationException;

View File

@ -0,0 +1,27 @@
package com.bpgroup.poc.admin.security.authentication.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum AuthenticationFailReason {
WRONG_LOGIN_ID("아이디 및 패스워드를 확인하세요."),
WRONG_PASSWORD("아이디 및 패스워드를 확인하세요."),
HAVE_NO_MENU("등록된 메뉴 권한이 없습니다. \n메뉴 등록 후 사용하시기 바랍니다."),
INTERNAL_ERROR("서버 내부 오류가 발생했습니다.");
private final String message;
public static AuthenticationFailReason from(Exception e) {
if (e instanceof AdministratorNotFoundException) {
return WRONG_LOGIN_ID;
} else if (e instanceof InvalidPasswordException) {
return WRONG_PASSWORD;
} else if (e instanceof DoNotHaveAnyMenuException) {
return HAVE_NO_MENU;
} else {
return INTERNAL_ERROR;
}
}
}

View File

@ -1,4 +1,4 @@
package com.bpgroup.poc.admin.app.login.exception; package com.bpgroup.poc.admin.security.authentication.exception;
public class DoNotHaveAnyMenuException extends Exception { public class DoNotHaveAnyMenuException extends Exception {
public DoNotHaveAnyMenuException() { public DoNotHaveAnyMenuException() {

View File

@ -1,4 +1,4 @@
package com.bpgroup.poc.admin.app.login.exception; package com.bpgroup.poc.admin.security.authentication.exception;
public class InvalidPasswordException extends Exception { public class InvalidPasswordException extends Exception {
public InvalidPasswordException(String message) { public InvalidPasswordException(String message) {

View File

@ -1,4 +0,0 @@
package com.bpgroup.poc.admin.values;
public class AdministratorMenu {
}

View File

@ -1,34 +1,16 @@
package com.bpgroup.poc.admin.web.login; package com.bpgroup.poc.admin.web.login;
import com.bpgroup.poc.admin.security.authentication.AuthenticationFailReason;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@Controller @Controller
@RequestMapping("/login") @RequestMapping("/login")
public class LoginController { public class LoginController {
@GetMapping @GetMapping
public String loginPage( public String loginPage() {
@RequestParam(required = false) AuthenticationFailReason error,
Model model
) {
if (error != null) {
model.addAttribute("errorMessage", getMessage(error));
}
return "login/login"; return "login/login";
} }
private String getMessage(AuthenticationFailReason error) {
return switch (error) {
case WRONG_LOGIN_ID, WRONG_PASSWORD -> "아이디 또는 비밀번호가 일치하지 않습니다.";
case HAVE_NO_MENU -> "등록된 메뉴가 없습니다.\n 메뉴 등록 후 이용해주세요.";
default -> "서버에 오류가 발생했습니다.";
};
}
} }

View File

@ -11,6 +11,7 @@
<script type="text/javascript" th:src="@{/js/pagehelper.js}"></script> <script type="text/javascript" th:src="@{/js/pagehelper.js}"></script>
<script type="text/javascript" th:src="@{/js/eventrouter.js}"></script> <script type="text/javascript" th:src="@{/js/eventrouter.js}"></script>
<script type="text/javascript" th:src="@{/js/httpinterceptor.js}"></script> <script type="text/javascript" th:src="@{/js/httpinterceptor.js}"></script>
<script type="text/javascript" th:src="@{/js/reqhelper.js}"></script>
<th:block th:replace="~{fragment/csrf/csrf :: applyCsrf}"></th:block> <th:block th:replace="~{fragment/csrf/csrf :: applyCsrf}"></th:block>
</head> </head>
@ -30,10 +31,24 @@
</p> </p>
</form> </form>
<button type="button" id="loginButton" class="btn_confirm btn_login">로그인</button> <button type="button" id="loginButton" class="btn_confirm btn_login">로그인</button>
<button type="button" id="loginJson" class="btn_confirm btn_login">로그인(JSON)</button>
</div> </div>
</div> </div>
<script type="text/javascript" th:inline="javascript"> <script type="text/javascript" th:inline="javascript">
document.getElementById('loginJson').addEventListener(('click'), function () {
Reqhelper.reqPostJson('/login', {
username: document.querySelector('input[name="loginId"]').value,
password: document.querySelector('input[name="password"]').value
}, function (res) {
if (res.resultCode === '0000') {
location.href = '/main';
} else {
PageHelper.showErrorModal(res.resultMessage);
}
});
});
const errorMessage = /*[[${errorMessage}]]*/ ''; const errorMessage = /*[[${errorMessage}]]*/ '';
if (errorMessage) { if (errorMessage) {