2023.07.19 120일차 Team Project - 2 채용박람회 비활성화아이디 찾기
120일차
1. 비활성화 아이디 찾기
아이디를 삭제 처리하면 해당 아이디와 관련된 게시글, 입사지원, 공고, 찜 등등 많은 부분을 같이 삭제 해주어야하는 문제가 생긴다. 그러나 현실에서 여러 사이트를 보면 탈퇴를 한다고 바로 탈퇴 처리를 하지 않고 그 사람이 탈퇴한다고 해서 글이 전부 삭제 되지 않는다.
이런부분에서 고민한점이 바로 탈퇴를 처리하지 않고 비활성화 시키면 되지 않을까라는 결론에 도달하게 되었다.
그래서 탈퇴시 비활성화 상태로 만들고 로그인시 해당 조건을 확인하고 예외를 발생시키려는 첫 시도가 있었다.
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Members members = mapper.selectByMemberId(username);
if (members == null) {
throw new UsernameNotFoundException(username + "해당 회원 조회 불가");
}
Boolean disabled = false;
//탈퇴회원 로그인 못하게 막아줌
if (members.getIsActive() == 0) {
throw new disabledIdException();
}
List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
for (String auth : members.getAuthority()) {
authorityList.add(new SimpleGrantedAuthority(auth));
}
UserDetails user = User.builder()
.username(members.getId())
.password(members.getPassword())
.authorities(List.of())
.authorities(members.getAuthority().stream().map(SimpleGrantedAuthority::new).toList())
.build();
return user;
}위와 같이 커스텀한 예외를 발생시켜 Exception Handler로 해당 예외가 발생하면 비활성화 아이디 찾기 페이지로 가게 하고 싶었다.
그러나 왠걸 userDetail에서 예외를 발생시키면 커스텀 예외가 발생하는 것이 아니라 커스텀 예외로 로그인 예외가 따로 발생하는 문제가 발생했다. 여기서 해당 예외로 예외처리를 하면 비활성화 아이디뿐만 아니라 다른 로그인 예외도 그 예외처리로 해결되는 문제가 있었다.
그래서 이 문제를 해결하기 위해 스프링 시큐리티 설정을 찾아보게 되었다.
참고한 블로그
https://goodteacher.tistory.com/597
찾아보던 중 로그인시 발생할 수 있는 상황에 따라 userDetail을 설정할 수 있다는 것을 알게 되었다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.formLogin()
.loginPage("/login/login")
.failureHandler((request, response, exception) -> {
String errorMessage;
if (exception instanceof LockedException) {
errorMessage = "계정이 잠겼습니다. 잠시 후에 다시 시도해주세요.";
} else if (exception instanceof DisabledException) {
errorMessage = "계정이 비활성화되었습니다. 관리자에게 문의하세요.";
} else if (exception instanceof BadCredentialsException) {
errorMessage = "아이디 또는 비밀번호가 잘못되었습니다.";
} else if (exception instanceof AccountExpiredException) {
errorMessage = "계정이 만료되었습니다. 관리자에게 문의하세요.";
} else if (exception instanceof CredentialsExpiredException) {
errorMessage = "비밀번호가 만료되었습니다. 비밀번호를 변경해주세요.";
} else {
errorMessage = "로그인에 실패하였습니다. 다시 시도해주세요.";
}
request.getSession().setAttribute("errorMessage", errorMessage);
response.sendRedirect("/login/loginfailure");
});
http.logout()
.logoutUrl("/login/logout")
.logoutSuccessUrl("/");
return http.build();
}스프링 시큐리티에서 상황마다 다 정리해놓앗다.
위의 코드에서는 다음과 같은 인증 실패 유형을 처리하고 있다.
LockedException: 계정이 잠겼을 때 처리
DisabledException: 비활성화된 계정일 때 처리
BadCredentialsException: 아이디 또는 비밀번호가 잘못되었을 때 처리
AccountExpiredException: 계정이 만료되었을 때 처리
CredentialsExpiredException: 비밀번호가 만료되었을 때 처리
기타: 그 외의 모든 인증 실패 시 기본 메시지 처리
각각의 예외에 따라 다른 에러 메시지를 생성하여 /login/loginfailure 페이지로 리다이렉션한다.
이러한 방식으로 다양한 인증 실패 시나리오를 처리하고 사용자에게 적절한 안내 메시지를 제공할 수 있다.
우리 프로젝트에서는 비활성화 아이디만 처리하면 되기 때문에 해당 조건에 대해서 먼저 userDetail에 정의해주었다.
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx
throw new UsernameNotFoundException(username + "해당 회원 조회 불가");
}
boolean isActive = members.getIsActive() == 1;
List<SimpleGrantedAuthority> authorityList = new ArrayList<>();
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundEx
UserDetails user = User.builder()
.username(members.getId())
.disabled(!isActive)
.password(members.getPassword())
.authorities(List.of())
.authorities(members.getAuthority().stream().map(SimpleGrantedAuthority::new).toList())
.build();
return user;
}boolean isActive = members.getIsActive() == 1; 멤버 상태가 활성화 비활성화 상태에 따라 true냐 false냐의 값을 생성한다.
그리고 UserDetails 객체를 설정할때 disabled메소드를 통해 비활성화 아이디인지 아닌지에 대한 설정을 한다.
이 값이 true라면 비활성화 상태인 UserDetails라는 것을 설정하게 된다.
이후 처리는 config파일에서 처리해주어야한다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf().disable();
http.formLogin()
.loginPage("/login/login")
.failureHandler((request, response, exception) -> {
String errorMessage = null;
int sessionTimeoutInMinutes = 5;
if (exception instanceof DisabledException) {
// 잠긴 계정의 경우 실패 처리 로직 구현
errorMessage = "비활성화아이디입니다.";
request.getSession().setMaxInactiveInterval(sessionTimeoutInMinutes);
request.getSession().setAttribute("errorMessage", errorMessage);
response.sendRedirect("/login/locked");
} else if (exception instanceof BadCredentialsException) {
errorMessage = "아이디 또는 비밀번호가 잘못되었습니다.";
request.getSession().setMaxInactiveInterval(sessionTimeoutInMinutes);
request.getSession().setAttribute("errorMessage", errorMessage);
response.sendRedirect("/login");
} else {
errorMessage = "로그인에 실패하였습니다. 다시 시도해주세요.";
request.getSession().setMaxInactiveInterval(sessionTimeoutInMinutes);
request.getSession().setAttribute("errorMessage", errorMessage);
response.sendRedirect("/login");
}
});
http.logout()
.logoutUrl("/login/logout")
.logoutSuccessUrl("/");
return http.build();
}일단 두가지 경우에 대해서 처리를 했다. 비활성화 아이디인 경우와 아이디비번이 틀렷을 경우이다.
각각 메시지를 가지고 해당 페이지들로 이동을 하게 된다.
메시지의 경우 세션에 담아서 이동하였으며 너무 긴 시간을 담고있으면 겹치는 경우가 발생해서 5초만 유지하도록 했다.
1.1 view ajax
비활성화 아이디의 경우 이메일 인증을 통해서 활성화 상태로 만들고자 하였다.
mailCheckBtn.addEventListener("click", function () {
const memberId = memberInput.value;
fetch(`/api/login/locked?memberId=${memberId}`, {
method: "GET",
})
.then(response => response.json())
.then(data => {
//이메일 있는지 확인
const status = data.status;
if (status === 'notFound') {
emailText.classList.remove("d-none");
emailText.classList.remove("text-primary");
emailText.classList.add("text-danger");
emailText.innerHTML = "존재하지 않는 회원 입니다.";
} else {
//있는지 확인 후 처리
const email = data.email;
fetch("/api/login/locked/send-email", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({email: email})
})
.then(response => response.json())
.then(data => {
const message = data.message;
if (message === 'success') {
emailText.classList.remove("d-none");
emailText.classList.remove("text-danger");
emailText.classList.add("text-primary");
emailText.innerHTML = "입력하신 아이디의 등록된 이메일로 인증번호가 발송되었습니다 확인해주세요.";
} else {
alert("메일전송에 문제가 발생했습니다. 관리자에게 문의해주세요");
}
})
.catch(error => {
console.log("error발생: " + error);
})
}
})
.catch(error => {
console.log("error발생: " + error);
})
});
const authCodeCheck = document.querySelector("#auth-code-check");
const mailConfirmInput = document.querySelector("#mail-confirm");
authCodeCheck.addEventListener("click", function () {
const inputAuthCode = mailConfirmInput.value;
const memberId = memberInput.value;
fetch("/api/login/locked/verification", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
inputAuthCode: inputAuthCode,
memberId: memberId
})
})
.then(response => response.json())
.then(data => {
const message = data.message;
if (message === 'success') {
alert("활성화되었습니다. 로그인창으로가서 로그인해주세요")
location.href = "/login/login";
} else {
alert("인증에 실패하였습니다. 다시한번 시도해주세요");
location.href = "/login/locked";
}
})
.catch(error => {
console.log("error발생: " + error);
})
});하나의 컨트롤러에 너무 많은 기능을 부여하면 단일 책임 원칙에 위배되기 때문에 세가지 단계로 나누었다.
물론 이 방식에서 전부 다 분리할 수는 없었다.
1.이메일이 있는지 없는지 확인하기
2.이메일이 있다면 인증번호를 메일로 보내기
3.인증번호 확인 후 활성화시키기
1.2 컨트롤러
컨트롤러는 각각의 역할을 수행하고 있다. 이메일 체크하기, 이메일 보내기, 이메일 확인하기
// 이메일 있는지 확인
@GetMapping
public ResponseEntity<Map<String, Object>> emailCheck(
@RequestParam String memberId) {
Map<String, Object> result = memberService.checkEmail(memberId);
return ResponseEntity.ok(result);
}
// 이메일 보내기
@PostMapping("/send-email")
public ResponseEntity<Map<String, Object>> sendEamil(
@RequestBody Members member,
HttpSession session) {
String email = member.getEmail();
Map<String, Object> result = new HashMap<>();
try {
mailService.sendMail(email, session);
result.put("message", "success");
} catch (Exception e) {
result.put("message", "fail");
}
return ResponseEntity.ok(result);
}
@PostMapping("/verification")
public ResponseEntity<Map<String, Object>> emailVerification(
@RequestBody Map<String, Object> map,
HttpSession session) {
Map<String, Object> result = memberService.active(map, session);
return ResponseEntity.ok(result);
}1.3 서비스
메일 관련은 다른 사람이 작성해둔 것을 그대로 사용했기 떼문에 넘어가도록 하겠다.
특이점은 메일에 담을때 session에 담아 두었다는 것이다.
이 session에 담은 인증코드와 입력한 인증코드를 비교할 것이다.
// 비활성화 아이디 해제
@Override
public Map<String, Object> active(Map<String, Object> map, HttpSession session) {
String inputAutoCode_ = (String) map.get("inputAuthCode");
Integer inputAutoCode = Integer.parseInt(inputAutoCode_);
Object memberId = map.get("memberId");
Integer authCode = (Integer) session.getAttribute("authenticatedNum");
if (authCode.equals(inputAutoCode)) {
mapper.active(memberId);
return Map.of("message", "success");
} else {
return Map.of("message", "fail");
}
}비활성화 아이디 상태에서 발생한 문제점이 있었다.
처음 equals를 사용해서 json으로 받아온 authcode(auth와 auto가 전혀다른데 자꾸 저런식으로 입력하는 실수가 발생했다 실수라서 남겨두도록 하겠다.)와 session에 담겨있는 authcode를 비교해야한다.
그러나 session에 담았던 authcode가 String인줄 알았으나 랜덤으로 생성된 Integer값이었고
입력한 authcode는 당연히 Json으로 넘어왔기 때문에 String이라는 문제점이 있었다.
그래서 첫 비교를 했을 때는 당연히 false였다.
그래서 문제를 찾기 위해 session을 강제로 String으로 변경하는 시도를 했지만
Integer 값이기 때문에 강제 캐스팅을 하면 당연히 오류가 발생했었다.
그래서 원인을 찾던 도중 다시 메일 서비스로 가보니 해당 session에 담는 값을 Integer로 담고 있다는 사실을 알게 되었다.
내가 짯던 코드가 아니기 때문에 이런 문제가 발생했었다.
그래서 결론적으로 Integer비교를 하는 것으로 도달했다.
Json으로 받아온 String값을 Integer.parseInt()를 이용해서 Intger값으로 만들고
session의 Object를 Integer로 변형 후 비교하게 되었다.
그랬더니 잘 해결되었다.
2. 404, 403오류 처리
Excetpion Handler를 사용하려고 했었기 때문에 이것을 삭제 해버리는 것은 아쉬워서 404오류와 403오류에 대해서 처리하기로 결정했다.
@ControllerAdvice
public class GlobalExceptionHandler {
// 404페이지 오류
@ExceptionHandler(NoHandlerFoundException.class)
public String handle404(NoHandlerFoundException ex, Model model) {
// 404 에러 처리 로직 구현
String message = "요청한 페이지를 찾을 수 없습니다.";
model.addAttribute("message", message);
return "error/404"; // 404 에러 페이지로 리디렉션
}
@ExceptionHandler(AccessDeniedException.class)
public String handle403(AccessDeniedException ex, Model model) {
// 403 에러 처리 로직 구현
String message = "권한이 없습니다.";
model.addAttribute("message", message);
return "error/403"; // 403 에러 페이지로 리디렉션
}
}방법은 간단했다. GlobalExceptionHandler 클래스를 만들고(이름은 상관없다.) @ControllerAdvice어노테이션을 달아주어 처리하면된다.
@ControllerAdvice는 전역적으로 컨트롤러(Controller) 클래스에 적용되는 공통 기능을 구현할 때 사용된다고 한다.
일반적으로 예외 처리, 바인딩 설정, 모델 객체 추가 등의 작업을 전역적으로 수행하고 싶을 때 유용하게 사용된다고 한다.
@ControllerAdvice 어노테이션을 사용하여 구현한 클래스는 전역에서 적용되며 다음과 같은 역할을 수행할 수 있다.
예외 처리: @ExceptionHandler 어노테이션을 사용하여 컨트롤러에서 발생하는 예외를 전역적으로 처리할 수 있다.
즉, 모든 컨트롤러에서 발생하는 예외를 한 곳에서 처리하고, 일관된 예외 응답을 제공할 수 있다.
모델 객체 공유: @ModelAttribute 어노테이션을 사용하여 모든 컨트롤러에서 공통적으로 사용되는 모델 객체를 전역으로 설정할 수 있다.
이를 통해 여러 컨트롤러에서 동일한 모델 객체를 반복해서 설정할 필요가 없어진다.
바인딩 설정: @InitBinder 어노테이션을 사용하여 특정 컨트롤러의 요청 바인딩을 전역적으로 설정할 수 있다.
예를 들어, 특정 데이터 타입의 변환, 유효성 검사 등을 설정할 수 있다.
이 역할 중에서 예외처리하는 부분으로 처리한 것이다.
404에러와 403에러의 경우
각각 NoHandlerFoundException와 AccessDeniedException가 발생한다고 한다.
그래서 각 예외가 발생했을 경우 각자에 맞는 페이지로 이동하도록 처리했다.
2023.07.31
비활성화 아이디에 대해서 짧은 식견으로 예외를 발생시켜 해결하고자 했다.
그러나 위 글에서도 볼 수 있듯이 스프링 시큐리티 설정에 여러 설정이 있고 이미 여러 상황을 가정해두었다는 것이다.
후에 스프링 시큐리티뿐만 아니라 스프링 전반적인 공부를 해나간다면 내가 모르지만 이미 다른 방법으로 해결했을 듯한 여러 기능들이 있을 것 같다. 스프링 Validation이나 그런 부분도 스프링이 지원하는 방식인 것처럼 수동으로 처리할 수 있지만 스프링 프레임워크가 발전하면서 담겨있는 것들을 잘 활용하는 것이 중요해 보인다.
<%--
<form:form modelAttribute="company" method="post" action="/api/user/recruiter/"
enctype="multipart/form-data">
<form:input id="input-company-name" path="companyName" type="text" class="form-control"
name="companyName"
placeholder="기업이름을 입력해주세요."/>
<form:errors path="companyName" cssClass="error"/>
<div class="mb-3">
<label for="input-registration-number" class="form-label">사업자등록번호 *</label>
<div class="form-text">- 포함 입력</div>
<form:input id="input-registration-number" path="registrationNumber" type="text" class="form-control"
name="registrationNumber"
placeholder="사업자등록번호를 입력해주세요"/>
</div>
<div class="mb-3">
<label for="input-number-of-employees" class="form-label">사원수 *</label>
<form:input id="input-number-of-employees" path="numberOfEmployees" type="number" class="form-control"
name="numberOfEmployees"/>
<form:errors path="numberOfEmployees" cssClass="error"/>
</div>
<div class="mb-3">
<label for="input-establishment-date" class="form-label">설립일 *</label>
<form:input id="input-establishment-date" path="establishmentDate" type="date" class="form-control"
name="establishmentDate"/>
<form:errors path="establishmentDate" cssClass="error"/>
</div>
<div class="mb-3">
<label for="input-revenue" class="form-label">매출액 *</label>
<form:input id="input-revenue" path="revenue" type="number" class="form-control" name="revenue"/>
<form:errors path="revenue" cssClass="error"/>
</div>
<div class="mb-3">
<label for="input-ceo-name" class="form-label">대표자명 *</label>
<form:input id="input-ceo-name" path="ceoName" type="text" class="form-control" name="ceoName"/>
<form:errors path="ceoName" cssClass="error"/>
</div>
<div class="mb-3">
<label for="input-industry-id" class="form-label">업종 *</label>
<form:select path="industryId" name="industryId" class="form-select" id="input-industry-id">
</form:select>
</div>
<div class="mb-3">
<label for="input-address" class="form-label">주소 *</label>
<div class="col-sm-6 mb-1">
<div class="input-group">
<input type="text" id="post-code" name="postalCode" class="form-control input-sm"
placeholder="우편번호" readonly>
<button class="btn btn-outline-secondary" name="address" type="button" id="search-address-btn">
주소검색
</button>
</div>
</div>
<input id="input-address" type="text" class="form-control mb-1" readonly placeholder="도로명 주소"/>
<form:input path="detailAddress" name="detailAddress" id="input-detail-address" type="text"
class="form-control mb-1" placeholder="상세주소"/>
</div>
<div class="mb-3">
<label for="form-file" class="form-label">첨부 파일 *</label>
<input class="form-control" name="files" type="file" id="form-file" multiple>
<div class="form-text">총 10MB, 하나의 파일을 1MB를 초과할 수 없습니다.</div>
</div>
<div>
<div class="form-text">정보가 충분하지 않을 시 신청이 보류되거나 반려될 수 있습니다. <br>
* 표시는 필수로 입력해야합니다.
</div>
<button id="submit-btn" type="submit" class="btn btn-primary">신청</button>
</div>
</div>--%>