프로젝트 개요 : Jump to SpringBoot 클론코딩 - 회원제 QnA 게시판 웹
프로젝트 환경 : IntelliJ, SpringBoot, JPA, H2
프로젝트 코드 : https://github.com/smkim9202/sbb
Spring Security(스프링 시큐리티)
스프링 기반 애플리케이션의 인증과 권한을 담당하는 스프링의 하위 프레임워크
- 인증(Authenticate)은 로그인을 의미한다.
- 권한(Authorize)은 인증된 사용자가 어떤 것을 할 수 있는지를 의미한다.
스프링 시큐리티는 기본적으로 인증되지 않은 사용자는 서비스를 사용 할 수 없게끔 되어 있다. 따라서 웹에 접근하기 위해서는 인증을 위한 로그인 화면이 먼저 나타난다. 설정을 통해 로그인이 필요 없는 부분에는 적용되지 않게 해야한다.
스프링 시큐리티 설치
파일명 : /프로젝트명/build.gradle
dependencies {
(... 생략 ...)
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
}
스프링 시큐리티 설정
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/SecurityConfig.java
package com.mysite.sbb;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
//스프링의 환경설정 파일임을 의미하는 애너테이션
@Configuration
//모든 요청 URL이 스프링 시큐리티의 제어를 받도록 만드는 애너테이션 :
//내부적으로 SpringSecurityFilterChain이 동작하여 URL필터가 적용
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception{
//모든 인증되지 않은 요청을 허락 : 로그인 없이도 모든 페이지 접근 가능
http.authorizeHttpRequests().antMatchers("/**").permitAll();
}
}
홈 접속시 "http.authorizeHttpRequests().antMatchers("/**").permitAll();" 설정전에는 로그인화면으로 넘어가고 설정 후에는 로그인 없이도 홈화면이 뜬다.
H2 콘솔
스프링 시큐리티 적용시 CSRF 기능이 동작하기 때문에 H2 콘솔 로그인시 403 Forbidden 오류가 발생한다. H2 콘솔은 스프링과 상관없는 일반 애플케이션으로 CSRF 토큰을 발행하는 기능이 없기 때문에 오류가 발생한다.
CSRF(cross site request forgery)는 웹 사이트 취약점 공격을 방지를 위해 사용하는 기술로 스프링 시큐리티가 CSRF토큰이 자동으로 생성한다.
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/SecurityConfig.java
package com.mysite.sbb;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception{
http.authorizeHttpRequests().antMatchers("/**").permitAll()
//http 객체의 설정을 이어서 할 수 있게 하는 메서드이다.
.and()
// /h2-console/로 시작하는 URL은 CSRF 검증을 하지 않는다는 설정
.csrf().ignoringAntMatchers("/h2-console/**")
//X-Frame-Options 헤더값을 sameorigin으로 설정하여 오류가 발생하지 않도록 설정
//frame에 포함된 페이지가 페이지를 제공하는 사이트와 동일한 경우에 계속 사용할 수 있다.
.and()
.headers()
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))
;
}
}
H2 콘솔은 예외로 처리 할 수 있도록 설정하기 전에는 콘솔 접속에 오류가 발생했고, 설정 후엔 정상 접속이 됐다.
회원가입
회원정보 엔티티(SiteUser)
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/SiteUser.java
package com.mysite.sbb.user;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.*;
@Getter
@Setter
@Entity
public class SiteUser { //스프링시큐리티에 User 클래스와 헷갈리지 않기 위해 SiteUser로 설정
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true) //unique=true : 유일한 값만 저장 가능(중복값 저장 불가)
private String username; //사용자 이름(사용자 ID)
private String password; //비밀번호
@Column(unique = true)
private String email; //이메일
}
User 리포지터리
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/UserRepository.java
package com.mysite.sbb.user;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<SiteUser, Long> {
}
User 서비스
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/UserService.java
package com.mysite.sbb.user;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@RequiredArgsConstructor
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public SiteUser create(String username, String email, String password) {
SiteUser user = new SiteUser();
user.setUsername(username);
user.setEmail(email);
//암호화를 위해 시큐리티의 BCryptPasswordEncoder 클래스를 사용하여 암호하 후 비밀번호 저장
//BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//passwordEncoder 객체를 직접 new로 생성하면 암호화 방식 변경시 사용한 모든 프로그램을 일일히 찾아서 수정해야 함
//=>SecurityConfig에 @Bean으로 등록해서 사용해서 해결
user.setPassword(passwordEncoder.encode(password));
this.userRepository.save(user);
return user;
}
}
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/SecurityConfig.java
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception{
... 생략...
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
회원가입 폼
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/UserCreateForm.java
package com.mysite.sbb.user;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UserCreateForm {
@Size(min = 3, max = 25)
@NotEmpty(message = "사용자ID는 필수항목입니다.")
private String username;
@NotEmpty(message = "비밀번호는 필수항목입니다.")
private String password1;
@NotEmpty(message = "비밀번호 확인은 필수항목입니다.")
private String password2;
@NotEmpty(message = "이메일은 필수항목입니다.")
@Email //해당 속성의 값이 이메일형식과 일치하는지를 검증
private String email;
}
회원가입 컨트롤러
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/UserController.java
package com.mysite.sbb.user;
import javax.validation.Valid;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Controller;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Controller
@RequestMapping("/user")
public class UserController {
private final UserService userService;
@GetMapping("/signup") //회원가입을 위한 템플릿을 렌더링
public String signup(UserCreateForm userCreateForm) {
return "signup_form";
}
@PostMapping("/signup") //회원가입을 진행
public String signup(@Valid UserCreateForm userCreateForm, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return "signup_form";
}
//비밀번호1, 비밀번호2가 동일한지 검증
if (!userCreateForm.getPassword1().equals(userCreateForm.getPassword2())) {
//bindingResult.rejectValue(필드명, 오류코드, 에러메시지)
bindingResult.rejectValue("password2", "passwordInCorrect",
"2개의 패스워드가 일치하지 않습니다.");
return "signup_form";
}
//회원가입시 사용자ID, 이메일주소 중복 확인 후 예외처리
try {
userService.create(userCreateForm.getUsername(),
userCreateForm.getEmail(), userCreateForm.getPassword1());
}catch(DataIntegrityViolationException e) {
e.printStackTrace();
bindingResult.reject("signupFailed", "이미 등록된 사용자입니다.");
return "signup_form";
}catch(Exception e) {
e.printStackTrace();
bindingResult.reject("signupFailed", e.getMessage());
return "signup_form";
}
return "redirect:/";
}
}
회원가입 템플릿
파일명 : signup_form.html
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<div class="my-3 border-bottom">
<div>
<h4>회원가입</h4>
</div>
</div>
<form th:action="@{/user/signup}" th:object="${userCreateForm}" method="post">
<div th:replace="form_errors :: formErrorsFragment"></div>
<div class="mb-3">
<label for="username" class="form-label">사용자ID</label>
<input type="text" th:field="*{username}" class="form-control">
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호</label>
<input type="password" th:field="*{password1}" class="form-control">
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호 확인</label>
<input type="password" th:field="*{password2}" class="form-control">
</div>
<div class="mb-3">
<label for="email" class="form-label">이메일</label>
<input type="email" th:field="*{email}" class="form-control">
</div>
<button type="submit" class="btn btn-primary">회원가입</button>
</form>
</div>
</html>
회원가입 실행
회원가입 실행 후 데이터 확인
로그인과 로그아웃
로그인 URL 등록
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/SecurityConfig.java
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception{
...생략...
.and()
//스프링 시큐리티의 로그인 설정을 담당하는 부분 :
//로그인 페이지의 URL은 /user/login이고 성공시에 디폴트 페이지는 루트URL(/)임
.formLogin()
.loginPage("/user/login")
.defaultSuccessUrl("/")
;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
로그인 템플릿 연결
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/UserController.java
... 생략 ...
public class UserController {
... 생략 ...
//login PostMapping 방식의 메서드는 스프링 시큐리티가 대신 처리해준다.
@GetMapping("/login")
public String login() {
return "login_form";
}
}
파일명 : login_form.html
login_form을 GET방식으로 매핑하면 로그인화면이 뜨고 POST방식의 매핑은 스프링 시큐리티가 대신 처리해준다.
시큐리티의 로그인이 실패할 경우에는 로그인 페이지로 다시 리다이렉트 되면서 파라미터로 error가 함께 전달된다.
error가 전달될 경우 "사용자ID 또는 비밀번호를 확인해 주세요"라는 오류메시지를 출력하도록 했다.
<html layout:decorate="~{layout}">
<div layout:fragment="content" class="container my-3">
<form th:action="@{/user/login}" method="post">
<div th:if="${param.error}">
<div class="alert alert-danger">
사용자ID 또는 비밀번호를 확인해 주세요.
</div>
</div>
<div class="mb-3">
<label for="username" class="form-label">사용자ID</label>
<input type="text" name="username" id="username" class="form-control">
</div>
<div class="mb-3">
<label for="password" class="form-label">비밀번호</label>
<input type="password" name="password" id="password" class="form-control">
</div>
<button type="submit" class="btn btn-primary">로그인</button>
</form>
</div>
</html>
UserSecurityService 서비스
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/UserRepository.java
UserSecurityService는 사용자를 조회하는 기능이 필요하므로 Username을 기준으로 조회하는 findByUsername 메서드를 User리포지터리에 추가
package com.mysite.sbb.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<SiteUser, Long> {
Optional<SiteUser> findByusername(String username);
}
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/UserRole.java
스프링 시큐리티는 권한도 관리한다. 인증후에 사용자에게 부여할 권한이 필요하다. 다음과 같이 ADMIN, USER 2개의 권한을 갖는 UserRole이 필요하다.
package com.mysite.sbb.user;
import lombok.Getter;
@Getter
public enum UserRole {
ADMIN("ROLE_ADMIN"),
USER("ROLE_USER");
UserRole(String value){
this.value = value;
}
private String value;
}
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/UserSecurityService.java
package com.mysite.sbb.user;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@RequiredArgsConstructor
@Service
public class UserSecurityService implements UserDetailsService {
//UserDetailsService : 스프링 시큐리티가 제공하는 인터페이스
//loadUserByUsername 메서드를 구현하도록 강제하는 인터페이스
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//loadUserByUsername 메서드 => 사용자명으로 비밀번호를 조회하여 리턴하는 메서드
//사용자명으로 SiteUser 객체를 조회하고 만약 사용자명에 해당하는 데이터가 없을 경우 UsernameNotFoundException 오류 발생
//사용자명이 "admin"인 경우에 ADMIN 권한을 부여하고 그 이외의 경우에는 USER 권한을 부여
//사용자명, 비밀번호, 권한을 입력으로 스프링 시큐리티의 User 객체를 생성하여 리턴
//스프링 시큐리티는 loadUserByUsername 메서드에 의해 리턴된 User 객체의 비밀번호가 화면으로부터 입력 받은 비밀번호와 일치하는 검사하는 로직을 내부적으로 가지고 있음
Optional<SiteUser> _siteUser = this.userRepository.findByusername(username);
if (_siteUser.isEmpty()) {
throw new UsernameNotFoundException("사용자를 찾을수 없습니다.");
}
SiteUser siteUser = _siteUser.get();
List<GrantedAuthority> authorities = new ArrayList<>();
if ("admin".equals(username)) {
authorities.add(new SimpleGrantedAuthority(UserRole.ADMIN.getValue()));
} else {
authorities.add(new SimpleGrantedAuthority(UserRole.USER.getValue()));
}
return new User(siteUser.getUsername(), siteUser.getPassword(), authorities);
}
}
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/SecurityConfig.java
package com.mysite.sbb;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.header.writers.frameoptions.XFrameOptionsHeaderWriter;
import com.mysite.sbb.user.UserSecurityService;
import lombok.RequiredArgsConstructor;
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final UserSecurityService userSecurityService;
(... 생략 ...)
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
//AuthenticationManagerBuilder : 스프링 시큐리티의 인증을 담당
auth.userDetailsService(userSecurityService).passwordEncoder(passwordEncoder());
//auth 객체에 위에서 작성한 UserSecurityService를 등록하여 사용자 조회를 UserSecurityService가 담당하도록 설정
//이때 비밀번호 검증에 사용할 passwordEncoder도 등록
}
}
로그인 실패시 오류메시지와 함께 로그인화면으로 돌아가기 / 로그인 성공시 메인화면으로 이동
로그인 / 로그아웃 링크
로그인 상태에선 "로그아웃" 링크로, 로그아웃 상태에선 "로그인" 링크로 바뀌게 하기 => 타임리프의 sec:authorize 속성
sec:authorize="isAnonymous()" : 로그인 되지 않은 경우에만 해당 엘리먼트가 표시 되는 속성
sec:authorize="isAuthenticated()" : 로그인 된 경우에만 해당 엘리먼트가 표시 되는 속성
파일명 : /프로젝트명/src/main/resources/templates/navbar.html
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link" sec:authorize="isAnonymous()" th:href="@{/user/login}">로그인</a>
<a class="nav-link" sec:authorize="isAuthenticated()" th:href="@{/user/logout}">로그인</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/user/signup}">회원가입</a>
</li>
</ul>
</div>
로그아웃 구현하기
파일명 : /프로젝트명/src/main/java/프로젝트패키지명/user패키지/SecurityConfig.java
@Override
protected void configure(HttpSecurity http) throws Exception{
//모든 인증되지 않은 요청을 허락 : 로그인 없이도 모든 페이지 접근 가능
http.authorizeHttpRequests().antMatchers("/**").permitAll()
//http 객체의 설정을 이어서 할 수 있게 하는 메서드이다.
...생략...
.and()
//스프링 시큐리티의 로그아웃 설정을 담당하는 부분 :
//로그아웃 페이지의 URL은 '/user/logout'이고 성공시 디폴트 페이지는 루트URL(/)임
.logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/user/logout"))
.logoutSuccessUrl("/")
//로그아웃시 생성된 사용자 세션도 삭제하도록 처리
.invalidateHttpSession(true)
;
}
'프로젝트 > clone coding' 카테고리의 다른 글
[Jump to SpringBoot : SBB] 프로젝트 개발과 서버 환경 분리 (0) | 2022.05.12 |
---|---|
[Jump to SpringBoot : SBB] 프로젝트 AWS 서버에 배포하기 (0) | 2022.05.09 |
[Jump to SpringBoot : SBB] 스프링부트 windows cmd창으로 빌드하고 실행 (0) | 2022.04.21 |
[Jump to SpringBoot : SBB] 서비스와 DTO (0) | 2022.04.20 |
[Jump to SpringBoot : SBB] thymeleaf 템플릿 엔진(+ROOT URL) (0) | 2022.04.19 |