[Spring Boot] Spring Security를 적용하여 로그인, 회원가입 구현하기

우롱차·2022년 12월 23일
0

Spring Boot

목록 보기
7/7

요구사항

🔨 프로젝트 생성

  • Gradle-Groovy와 Gradle-Kotlin이 새로 생겼다.

    • Gradle-Groovy가 기존에 사용하던 Gradle 버전이어서 Gradle-Groovy를 선택했다.

    • Gradle-Kotlin은 요즘 Android Studio에서 많이 쓰이고 있다고 한다.

  • 3.0.0 버전으로 생성하니 빌드 시 호환성 문제로 오류가 생겨서 2.7.6 버전으로 생성했다.


📄 프로젝트 구조

📎 application.properties 설정

# 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

📎 build.gradle 설정

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
}

💡 Spring Security 설정

📎 SecurityConfig 파일 작성

@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로 설정했기 때문에 usernameParameteremail로 설정해주었다.

📎 AuthProvider 작성

@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;
    }
}
  • UserDetailsServiceloadByUsername 메서드를 이용하지 않고 직접 DB의 user 정보를 가져오도록 구현했다.

  • 로그인 버튼을 눌렀을 때 POST로 전송되는 username(여기서는 email), password를 이용하여 사용자를 인증한다.

  • Token에는 사용자의 password와 개인정보는 저장하지 않고 Primary Key(ex. id), role, 자주 쓰이는 정보(ex. username)만 담는 것이 좋다.


💡 Controller 작성

📎 UserController

@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 작성

📎 UserService

@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 작성

📎 UserMapper 인터페이스

@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); // 회원 탈퇴
}

📎 UserMapper.xml

<?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>

📎 UserVo

@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;
}

💡 JSP 파일 작성

📎 loginPage.jsp

<%@ 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에 값을 넣어주는 코드

    • POST 요청을 보내도 Token에 담긴 값으로 CSRF 공격이 아니라고 판단하여 POST 요청을 막지 않는다.
  • 실행 화면

📎 signupPage.jsp

<%@ 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>
  • 실행 화면

📎 home.jsp

<%@ 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>
  • 실행 화면

📎 editPage.jsp

<%@ 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>
  • 실행 화면

📎 userList.jsp

<%@ 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>
  • 실행 화면

💡 logback 설정

📎 logback-local.xml

<?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

profile
아직 따끈따끈합니다🍵

2개의 댓글

comment-user-thumbnail
2023년 1월 20일

안녕하세요! 좋은 글 작성해주셔서 공부하는데 도움이 되고 있습니다.
혹시 회원가입할때 rawPassword cannot be null 라고 뜨는데 뭐가 잘못됐을까요?

1개의 답글