설계
주소 | method | 방식 | 리턴 |
---|---|---|---|
/member/login | GET | MVC | void |
/member/find/id | GET | REST | ResponseEntity<String> |
/member/change_password | GET | MVC | void |
POST | MVC | String |
Spring Security를 이용해 로그인과 로그아웃 처리를 해 주려고 한다. Controller를 기준으로 추가해 주어야 할 부분은 위와 같다.
src/main/java - com.example.demo - SecurityConfig
수정
package com.example.demo;
import org.springframework.context.annotation.*;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.*;
import org.springframework.security.config.annotation.web.builders.*;
import org.springframework.security.config.annotation.web.configuration.*;
import org.springframework.security.crypto.password.*;
import com.example.demo.service.LoginFailureHandler;
import com.example.demo.service.LoginService;
import com.example.demo.service.LoginSuccessHandler;
import lombok.AllArgsConstructor;
@AllArgsConstructor
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private PasswordEncoder passwordEncoder;
private LoginService loginService;
private LoginSuccessHandler successHandler;
private LoginFailureHandler failureHandler;
// 인증 프로바이더
@Bean
public DaoAuthenticationProvider daoAuthenticationProvider() {
// DaoAuthenticationProvider : id와 password로 인증할 수 있도록 하는 구현체
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setPasswordEncoder(passwordEncoder);
provider.setUserDetailsService(loginService);
return provider;
}
// 폼 로그인 활성화
@Override
protected void configure(HttpSecurity http) throws Exception {
// 로그인
http.formLogin().loginPage("/member/login").loginProcessingUrl("/member/login")
.usernameParameter("username").passwordParameter("password")
.successHandler(successHandler).failureHandler(failureHandler)
// 로그아웃
.and().logout().logoutUrl("/member/logout").logoutSuccessUrl("/")
// 403이 발생하면 이동할 경로를 지정 : /로 지정하면 /로 이동 후 / 페이지로 forward된다.
// ▶ 루트 페이지에서 에러 메시지를 출력하기는 애매하다.
// http.exceptionHandling().accessDeniedPage("/");
// 에러 페이지를 지정해 놓고 컨트롤러를 만들지 않으면?
// 403 발생 -> /system/e403으로 이동 시도 -> 없음 -> 404 발생
http.exceptionHandling().accessDeniedPage("/system/e403");
}
}
💡폼 로그인
브라우저를 이용해 아이디와 비밀번호를 입력하면 로그인 정보를 서버 측 세션에 저장하는 방식의 로그인이다. 세션은 쿠키를 기반으로, 사용자가 최초 접근하면 서버가 세션을 생성한 다음 쿠키로 사용자 컴퓨터에 저장한다. 세션은 세션이 없는 사용자가 접근했을 때 만들어지는 것이다.
로그인과 로그아웃을 관리하기 위해 LoginSuccessHandler
와 LoginFailureHandler
를 만들어 주려고 한다.
📝 403 에러 페이지를 지정해 놓았는데 404 에러 페이지가 뜬다면 내가 지정해 놓은 페이지의 컨트롤러를 만들었는지 확인해 보아야 한다.
src/main/java - com.example.demo.service - LoginFailureHandler
생성
package com.example.demo.service;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.stereotype.Component;
import com.example.demo.dao.MemberDao;
import com.example.demo.entity.Member;
import lombok.AllArgsConstructor;
@AllArgsConstructor
@Component
public class LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler {
private MemberDao dao;
// 오류 메시지를 띄워 주기 위해 HttpSession을 사용한다.
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
String username = request.getParameter("username");
Member member = dao.findById(username);
HttpSession session = request.getSession();
// 아이디가 틀렸다면
if (member == null) {
session.setAttribute("msg", "사용자를 찾을 수 없습니다.");
// 아이디가 틀린 게 아니라면 어떤 예외인가?
} else if (exception instanceof BadCredentialsException) {
// 1. 비밀번호가 틀렸다.
Integer loginFailCnt = member.getLoginFailCnt();
loginFailCnt++;
if (loginFailCnt < 5) {
session.setAttribute("msg", "비밀번호가" + loginFailCnt + "회 틀렸습니다. 5회 틀리면 계정이 잠깁니다.");
dao.update(Member.builder().username(username).loginFailCnt(loginFailCnt).build());
} else {
session.setAttribute("msg", "비밀번호가 5회 틀렸습니다. 계정이 잠겼습니다.");
dao.update(Member.builder().username(username).loginFailCnt(loginFailCnt).enabled(false).build());
}
} else if (exception instanceof DisabledException) {
// 2. 계정이 잠겼다
session.setAttribute("msg", "잠긴 계정입니다. 관리자에게 연락해 주세요.");
}
// 이동
new DefaultRedirectStrategy().sendRedirect(request, response, "/member/login");
}
}
로그인에 실패했을 때, 로그인 횟수를 세어 실패 횟수가 5회 이상이면 계정을 비활성화한다. 에러 메시지는 아이디가 틀렸을 때, 비밀번호가 틀렸을 때, 계정이 잠겼을 때 모두 띄운다.
📝 Controller에서는 RedirectAttributes를 이용해 일회성 에러 메시지를 띄울 수 있었지만, RedirectAttributes는 Controller에서만 사용 가능하다. 따라서
LoginFailureHandler
에서는 HttpSession을 이용한다.
src/main/java - com.example.demo.service - LoginSuccessHandler
생성
package com.example.demo.service;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.SavedRequest;
import org.springframework.stereotype.Component;
import com.example.demo.dao.MemberDao;
import com.example.demo.entity.Member;
import lombok.AllArgsConstructor;
@AllArgsConstructor
@Component
public class LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private MemberDao dao;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
String username = authentication.getName();
// 로그인 실패 횟수 리셋
Member member = dao.findById(username);
dao.update(Member.builder().username(username).loginFailCnt(0).build());
// 이동
SavedRequest savedRequest = new HttpSessionRequestCache().getRequest(request, response);
// 비밀번호 꺼내기
String password = request.getParameter("password");
// 임시 비밀번호를 통한 로그인
if (password.length() >= 20) {
HttpSession session = request.getSession();
session.setAttribute("msg", "임시 비밀번호로 로그인하셨습니다. 비밀번호를 반드시 변경해 주세요.");
new DefaultRedirectStrategy().sendRedirect(request, response, "/member/change_password");
} else {
// 일반 로그인
// 1. 가려던 곳이 있는 경우
if (savedRequest != null)
new DefaultRedirectStrategy().sendRedirect(request, response, savedRequest.getRedirectUrl());
// 2. 가려던 곳이 없는 경우 (루트 페이지로 이동)
else
new DefaultRedirectStrategy().sendRedirect(request, response, "/");
}
}
}
로그인에 성공했을 때, 로그인 실패 횟수를 리셋하고 이동시킨다. 사용자가 가려던 곳이 있다면 그곳으로 이동시키고, 없다면 루트 페이지로 이동시킨다.
💡 간단 개념 정리
Principal
스프링 시큐리티에서 인증한 사용자 객체를 읽어 올 수 있는 객체 타입. 최상위 인터페이스이기 때문에 이 타입으로 받으면 사용할 수 있는 메소드가 getName() 정도밖에 없다. ID 정보만을 필요로 할 때 사용한다.
Authentication
세션 정보를 보관하는 객체에서 필요한 정보를 뽑아내는 메소드를 가지고 있다. 따라서 실제로 인증 정보를 사용하기 위해 사용되는 객체 타입이다.
출처
스프링 Security_로그인_Principal 객체
memberMapper.xml
수정 <update id="update">
update member
<trim suffixOverrides="," prefix="set"> <!-- 뒤에 쉼표 지워 주고 앞에 set 넣어 준다! -->
<if test="password!=null">password=#{password},</if>
<if test="email!=null">email=#{email},</if>
<if test="enabled!=null">enabled=#{enabled},</if>
<if test="authority!=null">authority=#{authority},</if>
<if test="checkcode!=null">checkcode=null,</if>
<if test="count!=null">count=#{count},</if>
<if test="levels!=null">levels=#{levels},</if>
<!--★ 수정 부분! -->
<if test="loginFailCnt!=null">loginFailCnt=#{loginFailCnt},</if>
</trim>
where username=#{username}
</update>
DB의 member 테이블 재생성
drop table member;
create table member (
username varchar2(10 char),
password varchar2(60 char),
irum varchar2(10 char),
email varchar2(50 char),
birthday date,
joinday date default sysdate,
enabled number(1) default 0,
authority varchar2(10 char) default 'ROLE_USER',
checkcode varchar2(20 char),
count number(7) default 0,
levels varchar2(15),
-- ★ 수정 부분!
loginFailCnt number(1) default 0,
constraint member_pk_username primary key(username)
);
src/main/java - com.example.demo.controller - MemberController
수정
@PreAuthorize("isAnonymous()")
@GetMapping("/member/login")
public void login(HttpSession session, HttpServletRequest request) {
// 로그인에 실패한 경우
if (session.getAttribute("msg") != null) {
request.setAttribute("msg", session.getAttribute("msg"));
session.removeAttribute("msg");
}
}
@ResponseBody
@GetMapping("/member/find/id")
public ResponseEntity<String> findId(String email, HttpServletRequest request) {
if (request.isUserInRole("ROLE_USER") == false)
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("잘못된 작업입니다.");
String username = service.findId(email);
if (username == null)
return ResponseEntity.status(HttpStatus.CONFLICT).body("아이디를 찾지 못했습니다.");
return ResponseEntity.ok(username);
}
@PreAuthorize("isAuthenticated()")
@GetMapping("/member/change_password")
public void changePassword(HttpSession session, HttpServletRequest request) {
if (session.getAttribute("msg") != null) {
request.setAttribute("msg", session.getAttribute("msg"));
session.removeAttribute("msg");
}
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/member/change_password")
public String changePassword(String password, Principal principal) {
service.changePassword(password, principal.getName());
return "redirect:/";
}
change_password.html
생성<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<title>비밀번호 변경</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/main.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script th:inline="javascript">
$(document).ready(function() {
let msg = /*[[${msg}]]*/
if (msg != null)
$('#msg_div').css("display","block");
})
</script>
</head>
<body>
<div id="page">
<header id="header" th:replace="/fragments/header">
</header>
<nav id="nav" th:replace="/fragments/nav">
</nav>
<div id="main">
<aside id="aside" th:replace="/fragments/aside">
</aside>
<div class="alert alert-danger" id="msg_div" style="display:none;">
<strong>주의! </strong><span th:text="${msg}"></span>
</div>
<section id="section">
<form id="change_password_form" method="post" action="/member/change_password">
<input type="hidden" name="_csrf" th:value="${_csrf.token}">
<div class="form-group">
<label for="password">비밀번호</label>
<input id="password" type="password" name="password" class="form-control">
<span class="helper-text" id="password_msg"></span>
</div>
<div class="form-group">
<label for="password2">비밀번호 확인</label>
<input id="password2" type="password" class="form-control">
<span class="helper-text" id="password2_msg"></span>
</div>
<button class="btn btn-success" id="change_password">비밀번호 변경</button>
</form>
</section>
</div>
<footer id="footer" th:replace="/fragments/footer">
</footer>
</div>
</body>
</html>
src/main/resources - templates - member - login.html
수정
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security">
<title>로그인</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
<link rel="stylesheet" href="/css/main.css">
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script th:inline="javascript">
$(document).ready(function() {
// ★ 수정 부분!
let msg = /*[[${msg}]]*/
if (msg != null)
$('#msg_div').css("display","block");
$('#login').click(function(e) {
e.preventDefault();
const $username = $('#username').val().toUpperCase();
$('#username').val($username);
$('#login_form').submit();
})
})
</script>
</head>
<body>
<div id="page">
<header id="header" th:replace="/fragments/header">
</header>
<nav id="nav" th:replace="/fragments/nav">
</nav>
<div id="main">
<aside id="aside" th:replace="/fragments/aside">
</aside>
<div class="alert alert-danger" id="msg_div" style="display:none;">
<strong>서버 메시지 </strong><span th:text="${msg}"></span>
</div>
<section id="section">
<form id="login_form" method="post" action="/member/login">
<input type="hidden" name="_csrf" th:value="${_csrf.token}">
<div class="form-group">
<label for="username">아이디</label>
<input id="username" type="text" name="username" class="form-control">
<span class="helper-text" id="username_msg"></span>
</div>
<div class="form-group">
<label for="password">비밀번호</label>
<input id="password" type="password" name="password" class="form-control">
<span class="helper-text" id="password_msg"></span>
</div>
<button class="btn btn-success" id="login">로그인</button>
<a class="btn btn-warning" href="/member/find_id">아이디 찾기</a>
<a class="btn btn-info" href="/member/find_password">비밀번호 찾기</a>
</form>
</section>
</div>
<footer id="footer" th:replace="/fragments/footer">
</footer>
</div>
</body>
</html>