요구사항
- 이전에 세션을 이용하여 구현했던 로그인, 회원가입 등의 사용자 정보 관련 기능을 세션을 사용하지 않고 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 라고 뜨는데 뭐가 잘못됐을까요?