요구사항
- 이전에 세션을 이용하여 구현했던 로그인, 회원가입 등의 사용자 정보 관련 기능을 세션을 사용하지 않고 Spring Security를 적용하여 구현한다.
UserDetailsService를 사용하지 않고AuthenticationProvider를 사용하기
AuthenticationProvider가 더 범용으로 사용할 수 있기 때문- password를 암호화 하기

Gradle-Groovy와 Gradle-Kotlin이 새로 생겼다.
Gradle-Groovy가 기존에 사용하던 Gradle 버전이어서 Gradle-Groovy를 선택했다.
Gradle-Kotlin은 요즘 Android Studio에서 많이 쓰이고 있다고 한다.
3.0.0 버전으로 생성하니 빌드 시 호환성 문제로 오류가 생겨서 2.7.6 버전으로 생성했다.

# JSP
# .jsp 파일을 view로 사용하기 위해 prefix와 suffix를 설정하여 위치 지정
# Controller에서 view name을 반환하면 suffix/<view name>.jsp로 파일을 찾는다.
# ex) /WEB-INF/view/home.jsp
spring.mvc.view.prefix=/WEB-INF/view/
spring.mvc.view.suffix=.jsp
# DataSource
# DB 연결 설정
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.url=jdbc:mariadb://<DB 주소>:<포트 번호>/<DB 이름>
spring.datasource.username=<스키마 계정>
spring.datasource.password=<비밀번호>
# Mapper
# mapper.xml 파일이 위치하는 곳
mybatis.mapper-locations=mapper/**/*.xml
# Logback
# logback 설정 파일이 위치하는 곳
logging.config=classpath:logback-local.xml
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' // web
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.0' // mybatis
implementation 'org.springframework.boot:spring-boot-starter-security' // spring security
implementation 'org.apache.tomcat.embed:tomcat-embed-jasper' // jsp
implementation 'javax.servlet:jstl' // jstl
compileOnly 'org.projectlombok:lombok' // lombok
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' // mariadb
annotationProcessor 'org.projectlombok:lombok' // lombok
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat' // tomcat
testImplementation 'org.springframework.boot:spring-boot-starter-test' //test code
}
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 권한에 따라 허용하는 url 설정
// /login, /signup 페이지는 모두 허용, 다른 페이지는 인증된 사용자만 허용
http
.authorizeRequests()
.antMatchers("/login", "/signup").permitAll()
.anyRequest().authenticated();
// login 설정
http
.formLogin()
.loginPage("/login") // GET 요청 (login form을 보여줌)
.loginProcessingUrl("/auth") // POST 요청 (login 창에 입력한 데이터를 처리)
.usernameParameter("email") // login에 필요한 id 값을 email로 설정 (default는 username)
.passwordParameter("password") // login에 필요한 password 값을 password(default)로 설정
.defaultSuccessUrl("/"); // login에 성공하면 /로 redirect
// logout 설정
http
.logout()
.logoutUrl("/logout")
.logoutSuccessUrl("/"); // logout에 성공하면 /로 redirect
return http.build();
}
}
SecurityConfig에서 url마다 허용되는 권한과 login, logout 설정을 해주었다.
Spring Security에서는 로그인 창에 입력되는 id 값을 username, password 값을 password로 인식한다. (default)
로그인에 필요한 id 값을 email로 설정했기 때문에 usernameParameter를 email로 설정해주었다.
@Component
public class AuthProvider implements AuthenticationProvider {
@Autowired
private UserService userService;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
String email = (String) authentication.getPrincipal(); // 로그인 창에 입력한 email
String password = (String) authentication.getCredentials(); // 로그인 창에 입력한 password
PasswordEncoder passwordEncoder = userService.passwordEncoder();
UsernamePasswordAuthenticationToken token;
UserVo userVo = userService.getUserByEmail(email);
if (userVo != null && passwordEncoder.matches(password, userVo.getPassword())) { // 일치하는 user 정보가 있는지 확인
List<GrantedAuthority> roles = new ArrayList<>();
roles.add(new SimpleGrantedAuthority("USER")); // 권한 부여
token = new UsernamePasswordAuthenticationToken(userVo.getId(), null, roles);
// 인증된 user 정보를 담아 SecurityContextHolder에 저장되는 token
return token;
}
throw new BadCredentialsException("No such user or wrong password.");
// Exception을 던지지 않고 다른 값을 반환하면 authenticate() 메서드는 정상적으로 실행된 것이므로 인증되지 않았다면 Exception을 throw 해야 한다.
}
@Override
public boolean supports(Class<?> authentication) {
return true;
}
}
UserDetailsService의 loadByUsername 메서드를 이용하지 않고 직접 DB의 user 정보를 가져오도록 구현했다.
로그인 버튼을 눌렀을 때 POST로 전송되는 username(여기서는 email), password를 이용하여 사용자를 인증한다.
Token에는 사용자의 password와 개인정보는 저장하지 않고 Primary Key(ex. id), role, 자주 쓰이는 정보(ex. username)만 담는 것이 좋다.
@Controller
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/")
public String home(Model model) { // 인증된 사용자의 정보를 보여줌
Long id = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
// token에 저장되어 있는 인증된 사용자의 id 값
UserVo userVo = userService.getUserById(id);
userVo.setPassword(null); // password는 보이지 않도록 null로 설정
model.addAttribute("user", userVo);
return "home";
}
@GetMapping("/userList")
public String getUserList(Model model) { // User 테이블의 전체 정보를 보여줌
List<UserVo> userList = userService.getUserList();
model.addAttribute("list", userList);
return "userListPage";
}
@GetMapping("/login")
public String loginPage() { // 로그인되지 않은 상태이면 로그인 페이지를, 로그인된 상태이면 home 페이지를 보여줌
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof AnonymousAuthenticationToken)
return "loginPage";
return "redirect:/";
}
@GetMapping("/signup")
public String signupPage() { // 회원 가입 페이지
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof AnonymousAuthenticationToken)
return "signupPage";
return "redirect:/";
}
@PostMapping("/signup")
public String signup(UserVo userVo) { // 회원 가입
try {
userService.signup(userVo);
} catch (DuplicateKeyException e) {
return "redirect:/signup?error_code=-1";
} catch (Exception e) {
e.printStackTrace();
return "redirect:/signup?error_code=-99";
}
return "redirect:/login";
}
@GetMapping("/update")
public String editPage(Model model) { // 회원 정보 수정 페이지
Long id = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UserVo userVo = userService.getUserById(id);
model.addAttribute("user", userVo);
return "editPage";
}
@PostMapping("/update")
public String edit(UserVo userVo) { // 회원 정보 수정
Long id = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
userVo.setId(id);
userService.edit(userVo);
return "redirect:/";
}
@PostMapping("/delete")
public String withdraw(HttpSession session) { // 회원 탈퇴
Long id = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (id != null) {
userService.withdraw(id);
}
SecurityContextHolder.clearContext(); // SecurityContextHolder에 남아있는 token 삭제
return "redirect:/";
}
}
@Service
public class UserService {
@Autowired
private UserMapper userMapper;
private final PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
public List<UserVo> getUserList() {
return userMapper.getUserList();
}
public UserVo getUserById(Long id) {
return userMapper.getUserById(id);
}
public UserVo getUserByEmail(String email) {
return userMapper.getUserByEmail(email);
}
public void signup(UserVo userVo) { // 회원 가입
if (!userVo.getUsername().equals("") && !userVo.getEmail().equals("")) {
// password는 암호화해서 DB에 저장
userVo.setPassword(passwordEncoder.encode(userVo.getPassword()));
userMapper.insertUser(userVo);
}
}
public void edit(UserVo userVo) { // 회원 정보 수정
// password는 암호화해서 DB에 저장
userVo.setPassword(passwordEncoder.encode(userVo.getPassword()));
userMapper.updateUser(userVo);
}
public void withdraw(Long id) { // 회원 탈퇴
userMapper.deleteUser(id);
}
public PasswordEncoder passwordEncoder() {
return this.passwordEncoder;
}
}
@Mapper
public interface UserMapper {
List<UserVo> getUserList(); // User 테이블 가져오기
void insertUser(UserVo userVo); // 회원 가입
UserVo getUserByEmail(String email); // 회원 정보 가져오기
UserVo getUserById(Long id);
void updateUser(UserVo userVo); // 회원 정보 수정
void deleteUser(Long id); // 회원 탈퇴
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="<package name>.mapper.UserMapper">
<!-- User 테이블 가져오기 -->
<select id="getUserList" resultType="<package name>.vo.UserVo">
SELECT *
FROM User
</select>
<!-- 회원가입 -->
<insert id="insertUser">
INSERT INTO User
(name, username, email, password, address, phone, website, company)
VALUES
(#{name}, #{username}, #{email}, #{password}, #{address}, #{phone}, #{website}, #{company})
</insert>
<!-- 회원 정보 가져오기 -->
<select id="getUserByEmail" resultType="<package name>.vo.UserVo">
SELECT *
FROM User
WHERE email = #{email}
</select>
<select id="getUserById" resultType="<package name>.vo.UserVo">
SELECT *
FROM User
WHERE id = #{id}
</select>
<!-- 회원정보 수정 -->
<update id="updateUser">
UPDATE User
SET name = #{name},
username = #{username},
email = #{email},
password = #{password},
address = #{address},
phone = #{phone},
website = #{website},
company = #{company}
WHERE id = #{id}
</update>
<!-- 탈퇴 -->
<delete id="deleteUser">
DELETE
FROM User
WHERE id = #{id}
</delete>
</mapper>
@Data
public class UserVo {
private Long id;
private String name;
private String username;
private String email;
private String password;
private String address;
private String phone;
private String website;
private String company;
}
<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Login</title>
</head>
<body>
<form action="/auth" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
<h2>로그인</h2>
<div>
<input type="text" name="email" placeholder="Email"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<button type="submit">로그인</button>
<button type="button" onclick="location.href='signup'">회원가입</button>
</form>
</body>
</html>
Spring Security에서는 기본적으로 CSRF 공격을 방어한다.
CSRF Token을 설정해주지 않으면 jsp에서 보내는 POST 요청을 모두 막기 때문에 로그인, 회원가입 기능이 작동하지 않아 인증 과정을 진행할 수 없다.
이를 위해 JSP에서 POST 요청을 보낼 때 CSRF Token에 값을 넣어 함께 보내고, Spring Security가 token 값을 확인하여 자신이 내려준 값이 맞는지 확인하는 방식으로 CSRF 공격을 판단한다.
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />: CSRF Token에 값을 넣어주는 코드
실행 화면

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Sign Up</title>
</head>
<body>
<h2>회원가입</h2>
<form action="/signup" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
<div>
<input type="text" name="name" placeholder="Name"/>
</div>
<div>
<input type="text" name="username" placeholder="*Username"/>
</div>
<div>
<input type="text" name="email" placeholder="*Email"/>
</div>
<div>
<input type="password" name="password" placeholder="Password"/>
</div>
<div>
<input type="text" name="address" placeholder="Address"/>
</div>
<div>
<input type="text" name="phone" placeholder="Phone"/>
</div>
<div>
<input type="text" name="website" placeholder="Website"/>
</div>
<div>
<input type="text" name="company" placeholder="Company"/>
</div>
<button type="submit">회원가입</button>
</form>
</body>
</html>

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Home</title>
</head>
<body>
<h2>${user.username}님의 회원 정보</h2>
<p>name: ${user.name}</p>
<p>username: ${user.username}</p>
<p>email: ${user.email}</p>
<p>password: ${user.password}</p>
<p>address: ${user.address}</p>
<p>phone: ${user.phone}</p>
<p>website: ${user.website}</p>
<p>company: ${user.company}</p>
<button type="button" onclick="location.href='update'">수정하기</button>
<form action="/logout" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
<button type="submit">로그아웃</button>
</form>
<form action="/delete" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
<button type="submit">탈퇴하기</button>
</form>
</body>
</html>

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Modify Information</title>
</head>
<body>
<h2>회원 정보 수정</h2>
<form action="/update" method="post">
<input type="hidden" name="${_csrf.parameterName}" value="${_csrf.token}" />
<p>
Name<br>
<input type="text" name="name" value="${user.name}"/>
</p>
<p>
Username<br>
<input type="text" name="username" value="${user.username}"/>
</p>
<p>
Email<br>
<input type="text" name="email" value="${user.email}"/>
</p>
<p>
Password<br>
<input type="password" name="password" placeholder="Password를 입력해주세요"/>
</p>
<p>
Address<br>
<input type="text" name="address" value="${user.address}"/>
</p>
<p>
Phone<br>
<input type="text" name="phone" value="${user.phone}"/>
</p>
<p>
Website<br>
<input type="text" name="website" value="${user.website}"/>
</p>
<p>
Company<br>
<input type="text" name="company" value="${user.company}"/>
</p>
<button type="submit">저장하기</button>
</form>
</body>
</html>

<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core"%>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>User List</title>
</head>
<body>
<h2>User List</h2>
<table>
<tr>
<th>id</th>
<th>name</th>
<th>username</th>
<th>email</th>
<th>password</th>
<th>address</th>
<th>phone</th>
<th>website</th>
<th>company</th>
</tr>
<c:forEach items="${list}" var="u">
<tr>
<td>${u.id}</td>
<td>${u.name}</td>
<td>${u.username}</td>
<td>${u.email}</td>
<td>${u.password}</td>
<td>${u.address}</td>
<td>${u.phone}</td>
<td>${u.website}</td>
<td>${u.company}</td>
</tr>
</c:forEach>
</table>
</body>
</html>

<?xml version="1.0" encoding="UTF-8"?>
<!-- 60초마다 설정 파일의 변경을 확인하여 변경시 갱신 -->
<configuration scan="true" scanPeriod="60 seconds">
<!-- 로그 파일이 저장될 경로 -->
<property name="LOG_PATH" value="log"/>
<!-- 로그 파일 이름 -->
<property name="LOG_FILE_NAME" value="wrsungSpringSecurity"/>
<!-- 로그 출력 패턴 -->
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%logger{40}] - %msg%n"/>
<!-- 로그 레벨 -->
<!--
1) ERROR : 오류 메시지 표시
2) WARN : 경고성 메시지 표시
3) INFO : 정보성 메시지 표시
4) DEBUG : 디버깅하기 위한 메시지 표시
5) TRACE : Debug보다 훨씬 상세한 메시지 표시
아래에서는 info로 설정하였는데, 이 경우엔 INFO보다 위에 있는 DEBUG와 TRACE는 표시하지 않는다.
-->
<property name="LOG_LEVEL" value="info"/>
<!-- CONSOLE에 로그 출력 세팅 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<Pattern>${LOG_PATTERN}</Pattern>
</encoder>
</appender>
<!-- File에 로그 출력 세팅 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<!-- 파일 경로 설정 -->
<file>${LOG_PATH}/${LOG_FILE_NAME}.log</file>
<!-- 출력패턴 설정-->
<encoder class="ch.qos.logback.classic.encoder.PatternLayoutEncoder">
<pattern>${LOG_PATTERN}</pattern>
</encoder>
<!-- Rolling 정책 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<!-- .gz,.zip 등을 넣으면 자동 일자별 로그파일 압축 -->
<fileNamePattern>${LOG_PATH}/%d{yyyy-MM, aux}/${LOG_FILE_NAME}.%d{yyyy-MM-dd}.log</fileNamePattern>
<!-- 일자별 로그파일 최대 보관주기(~일), 해당 설정일 이상된 파일은 자동으로 제거-->
<!-- <maxHistory>30</maxHistory> -->
<!-- 로그 파일 최대 보관 크기. 최대 크기를 초과하면 가장 오래된 로그 자동 제거 -->
<totalSizeCap>20GB</totalSizeCap>
</rollingPolicy>
</appender>
<!-- 로그 전역 세팅 -->
<root level="${LOG_LEVEL}">
<!-- 위에 설정한 콘솔 설정 추가 -->
<appender-ref ref="CONSOLE"/>
<!-- 위에 설정한 파일 설정 추가 -->
<appender-ref ref="FILE"/>
</root>
</configuration>
프로젝트 내에 로그 파일이 담겨있는 경우 gitignore에서 로그 파일은 업로드되지 않도록 설정해주는 것이 좋다.
📌 Reference
💻 Source Code
https://github.com/wooryung/Spring_Security_Basic.git
안녕하세요! 좋은 글 작성해주셔서 공부하는데 도움이 되고 있습니다.
혹시 회원가입할때 rawPassword cannot be null 라고 뜨는데 뭐가 잘못됐을까요?