Spring Security
Spring 어플리케이션의 보안(인증, 권한, 인가)을 담당하는 Spring 하위 프레임워크
보안과 관련하여 체계적으로 다양한 옵션을 제공함
Filter를 기반으로 동작 -> MVC와 분리
인증(Authentication): 해당 사용자가 본인이 맞는지
인가(Authorization): 인증된 사용자가 요청한 자원에 접근 가능한지
접근 주체(Principal): 보안 시스템이 작동하고 있는 어플리케이션에 접근하는 주체(유저)
Credential(비밀번호): 보안 시스템이 작동하고 있는 어플리케이션에 접근하는 주체(유저)의 비밀번호
권한: 인증된 주체가 어플리케이션의 동작을 수행할 수 있도록 허락되었는지
Spring Security 동작 순서
1. 사용자가 아이디와 비밀번호로 로그인을 요청함
2. AuthenticationFilter에서 UsernamePasswordAuthenticationToken을 생성하여 AuthenticationManager에게 전달함
3. AuthenticationManager는 등록된 AuthenticationProvider를 조회하여 인증을 요구함
4. AuthenticaionProvider는 UserDetailsService를 통해 입력받은 아이디에 대한 사용자 정보를 DB에서 조회함
5. 입력 받은 비밀번호를 암호화하여 DB의 비밀번호와 매칭되는 경우 인증이 성공된 UsernameAuthenticationToken을 생성하여 AuthenticaionManager로 반환함
6. AuthenticaionManager는 UsernameAuthenticaionToken을 AuthenticaionFilter로 전달함
7. AuthenticationFilter는 전달받은 UsernameAuthenticationToken을 LoginSuccessHandler로 전송하고, SecurityContextHolder에 저장함
spring boot에 spring security 적용하여 login 구현하기
1. gradle dependency 추가
- build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
}
2. Security Config 설정
@Configuration
@EnableWebSecurity
@AllArgsConstructor
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private MemberService memberService;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(WebSecurity web) throws Exception {
// static 디렉터리의 하위 파일 목록은 인증 무시 ( = 항상 통과 )
web.ignoring().antMatchers("/css/**", "/js/**", "/img/**", "/lib/**");
}
@Override
public void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(memberService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
// 페이지 권한 설정
.antMatchers("/admin/**").hasRole("ADMIN")
.antMatchers("/mypage/**").hasRole("MEMBER")
.antMatchers("/movie/mylist").hasRole("MEMBER")
.antMatchers("/").permitAll()
// 로그인 설정
.and().formLogin()
.loginPage("/login") // 로그인 페이지
.defaultSuccessUrl("/") // 로그인 성공 후 이동할 페이지
.permitAll() // 모든 사람에게 권한
// 로그아웃 설정
.and().logout()
.logoutRequestMatcher(new AntPathRequestMatcher("/logout")) // 로그아웃 페이지
.logoutSuccessUrl("/") // 로그아웃 성공 후 이동할 페이지
.invalidateHttpSession(true) // 로그아웃 후 세션 모두 삭제
// 권한 없을 때
.and().exceptionHandling()
.accessDeniedPage("/denied");
}
}
@EnableWebSecurity
Spring Security 사용을 위한 어노테이션
WebSecurityConfigurerAdapter
Spring Security 설정 파일로 필수로 상속 받아야 함
public void configure(AuthenticationManagerBuilder auth)
Spring Security가 사용자를 인증하는 방법이 담긴 객체
public void configure(WebSecurity web)
Spring Security 규칙을 무시하는 URL 규칙
예시: static 파일(js, css, img, ...)
protected void configure(HttpSecurity http)
Spring Security 규칙
public PasswordEncoder passwordEncoder()
비밀번호 암호화
3. 회원 Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity(name = "member")
public class MemberEntity implements UserDetails {
@Id
//@GeneratedValue(strategy = GenerationType.IDENTITY)
private String id;
private String email;
private String passwd;
private String first_name;
private String last_name;
private String gender;
@Column(name = "birthDt")
private Date birthDt;
private String phone;
private String nickname;
private String code_id;
private boolean is_enabled;
@Column(name = "createDt")
private Date createDt;
@Column(name = "updateDt")
private Date updateDt;
private String etc;
// 사용자의 권한을 콜렉션 형태로 반환
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Set<GrantedAuthority> roles = new HashSet<>();
for (String role : "".split(",")) {
roles.add(new SimpleGrantedAuthority(role));
}
return roles;
}
// 비밀번호 반환
@Override
public String getPassword() {
return passwd;
}
// username 반환 (unique한 값)
@Override
public String getUsername() {
return email;
}
// 계정 만료 여부 반환
// 만료 -> false
@Override
public boolean isAccountNonExpired() {
return true;
}
// 계정 잠금 여부 반환
// 만료 -> false
@Override
public boolean isAccountNonLocked() {
return true;
}
// password 만료 여부 반환
// 만료 -> false
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 계정 사용 가능 여부 반환
// 사용 불가 -> false
@Override
public boolean isEnabled() {
return true;
}
}
UserDetails 상속 받아 사용
필수로 override 해야 하는 메소드
getAuthorities()
getPassword()
getUsername()
isAccountNonExpired()
isAccountNonLocked()
isCredentialsNonExpired()
isEnabled()
4. 회원 Repository
@Repository
public interface MemberRepository extends JpaRepository<MemberEntity, Long> {
Optional<MemberEntity> findByEmail(String email);
}
5. 회원 Service
@RequiredArgsConstructor
@Service
public class MemberService implements UserDetailsService {
@Autowired
private MemberRepository memberRepository;
// 회원 정보 가져오기
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Optional<MemberEntity> userEntityWrapper = memberRepository.findByEmail(email);
if(userEntityWrapper.isEmpty()) {
throw new UsernameNotFoundException(email);
}
MemberEntity userEntity = userEntityWrapper.get();
List<GrantedAuthority> authorities = new ArrayList<>();
if (("admin@example.com").equals(email)) {
authorities.add(new SimpleGrantedAuthority(Role.ADMIN.getValue()));
} else {
authorities.add(new SimpleGrantedAuthority(Role.MEMBER.getValue()));
}
return new UserCustom(userEntity.getEmail(), userEntity.getPassword(),
userEntity.getId(), userEntity.getNickname(), authorities);
}
}
UserDetailsService 상속 받아 사용
필수 override 메소드
loadUserByUsername()
보통을 UserDetails를 그대로 return하지만 추가적으로 사용하는 정보들이 있을 경우 직접 Custom한 UserCustom을 return하기도 함
UserCustom은 User를 상속 받아 사용함
6. UserCustom
@Getter
@Setter
@ToString
public class UserCustom extends User {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
// 추가할 유저 정보
private String id;
private String nickname;
public UserCustom(String username, String password, String id, String nickname, Collection<? extends GrantedAuthority> authorities) {
super(username, password, authorities);
this.id = id;
this.nickname = nickname;
}
}
User를 상속 받은 후 파라미터를 추가하여 사용
lombok @Data를 사용하면 constructor 도중 에러가 발생하므로 사용하지 않음
7. 로그인 form
<form action="/login" method="post" class="validation-form" novalidate>
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
<div class="mb-3"><label for="username">이메일</label>
<input type="text" class="form-control" id="username" name="username" placeholder="you@example.com" required>
<div class="invalid-feedback"> 이메일을 입력해주세요.</div>
</div>
<div class="mb-3"><label for="password">비밀번호</label>
<input type="password" class="form-control" id="password" name="password" placeholder="" value="" required>
<div class="invalid-feedback"> 비밀번호를 입력해주세요.</div>
</div>
<hr class="mb-4">
<button class="btn btn-dark btn-lg btn-block" style="margin-bottom: 355px;" type="submit">Login</button>
</form>
Spring Security를 이용하여 로그인을 구현할 때 id(일반적으로 로그인 할 때 사용하는 id를 의미)의 name은 username으로 설정해야 함
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}" />
서버에 들어온 요청이 실제 서버에서 허용한 요청이 맞는지 확인하기 위해 추가
GET 방식에서는 login이 불가능
token값이 없을 시 에러 페이지로 이동하게 됨
spring boot에 spring security 적용하여 logout 구현하기
@GetMapping("/logout")
public String logout(HttpServletRequest request, HttpServletResponse response) {
new SecurityContextLogoutHandler().logout(request, response,
SecurityContextHolder.getContext().getAuthentication());
return "redirect:/";
}
GET 요청에 대한 logout
WebSecurityConfig에서 로그아웃 설정을 통해 POST 요청 + csrf 요청에 대한 logout은 이미 구현됨
spring boot에 spring security 적용하여 join구현하기
1. Controller
// 회원가입 form 페이지
@GetMapping("/join")
public String join() {
return "home/join";
}
// 회원가입 처리
@PostMapping("/join")
public String join(MemberDto memberDto) {
try {
memberService.join(memberDto);
return "redirect:/login";
} catch (DuplicateKeyException e) {
return "redirect:/join?error";
} catch (Exception e) {
return "redirect:/join?error";
}
}
2. 회원 Service에 method 추가
// 회원가입(spring security)
public String join(MemberDto memberDto) {
// 이메일, 닉네임 중복 체크
if(validateDuplicateEmail(memberDto.getEmail()) == false) {
throw new DuplicateKeyException(memberDto.getEmail());
} else if(validateDuplicateNickname(memberDto.getNickname()) == false) {
throw new DuplicateKeyException(memberDto.getNickname());
}
// 비밀번호 암호화
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
memberDto.setPasswd(encoder.encode(memberDto.getPasswd()));
Date date = new Date();
SimpleDateFormat transFormat = new SimpleDateFormat("yyyyMMddHHmmss");
String id = transFormat.format(date);
id = memberRepository.save(MemberEntity.builder()
.id(id)
.email(memberDto.getEmail()).passwd(memberDto.getPasswd())
.first_name(memberDto.getFirst_name()).last_name(memberDto.getLast_name())
.gender(memberDto.getGender()).birthDt(memberDto.getBirthDt()).phone(memberDto.getPhone()).nickname(memberDto.getNickname())
.code_id("M04").is_enabled(true).createDt(new Date()).updateDt(new Date())
.build()).getId();
return id;
}
// 이메일 중복 체크
public boolean validateDuplicateEmail(String email) {
Optional<MemberEntity> member = memberRepository.findByEmail(email);
if(member.isEmpty()) return true;
else return false;
}
// 별명(닉네임) 중복 체크
public boolean validateDuplicateNickname(String nickname) {
Optional<MemberEntity> member = memberRepository.findByNickname(nickname);
if(member.isEmpty()) return true;
else return false;
}
기능은 각자 필요하게 추가, 수정
3. 회원가입 form
<form th:action="@{/join}" method="post" class="validation-form" novalidate id="joinForm">
<div class="row">
<div class="col-md-6 mb-3"><label for="first_name">이름</label>
<input type="text" class="form-control" id="first_name" name="first_name" placeholder="" value="" required>
<div class="invalid-feedback"> 이름을 입력해주세요.</div>
</div>
<div class="col-md-6 mb-3"><label for="last_name">성(이름)</label>
<input type="text" class="form-control" id="last_name" name="last_name" placeholder="" value="" required>
<div class="invalid-feedback"> 성(이름)을 입력해주세요.</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label for="email">이메일</label>
<input type="email" class="form-control" id="email" name="email" placeholder="you@example.com" required>
<div class="invalid-feedback"> 이메일을 입력해주세요.</div>
</div>
<div class="col-md-6 mb-3">
<a class="btn btn-primary" style="margin-top: 25px;" onclick="validateDuplicateEmail()">중복확인</a>
</div>
</div>
<!--<div class="mb-3"><label for="email">이메일</label>
<input type="email" class="form-control" id="email" name="email" placeholder="you@example.com" required>
<div class="invalid-feedback"> 이메일을 입력해주세요.</div>
</div>-->
<div class="row">
<div class="col-md-6 mb-3"><label for="passwd">비밀번호</label>
<input type="password" class="form-control" id="passwd" name="passwd" oninput="inputPasswd()" required>
<div class="invalid-feedback"> 비밀번호를 입력해주세요.</div>
</div>
<div class="col-md-6 mb-3"><label for="passwd_check">비밀번호 확인</label>
<input type="password" class="form-control" id="passwd_check" name="passwd_check" placeholder="" value="" required>
<div class="invalid-feedback"> 비밀번호 확인을 입력해주세요.</div>
</div>
</div>
<div class="mb-3"><label for="phone">핸드폰번호</label>
<input type="text" class="form-control" id="phone" name="phone" placeholder="" required>
<div class="invalid-feedback"> 핸드폰번호를 입력해주세요.</div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label for="gender">성별</label>
<select class="form-control" id="gender" name="gender">
<option value="F">여자</option>
<option value="M">남자</option>
</select>
<div class="invalid-feedback"> 성별을 입력해주세요.</div>
</div>
<div class="col-md-6 mb-3"><label for="birthDtStr">생년월일</label>
<input type="date" class="form-control" id="birthDtStr" name="birthDtStr" placeholder="" value="" required>
<div class="invalid-feedback"> 생년월일을 입력해주세요.</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3"><label for="nickname">별명(닉네임)</label>
<input type="text" class="form-control" id="nickname" name="nickname" placeholder="" required>
<div class="invalid-feedback"> 별명(닉네임)을 입력해주세요.</div>
</div>
<div class="col-md-6 mb-3">
<a class="btn btn-primary" style="margin-top: 25px;" onclick="validateDuplicateNickname()">중복확인</a>
</div>
</div>
<!--<div class="mb-3"><label for="nickname">별명(닉네임)</label>
<input type="email" class="form-control" id="nickname" name="nickname" placeholder="" required>
<div class="invalid-feedback"> 별명(닉네임)을 입력해주세요.</div>
</div>-->
<div class="custom-control custom-checkbox">
<input type="checkbox" class="custom-control-input" id="agreement" required>
<label class="custom-control-label" for="agreement">개인정보 수집 및 이용에 동의합니다.</label>
</div>
<hr class="mb-4">
<div class="mb-4"></div>
<button class="btn btn-dark btn-lg btn-block" style="margin-bottom:3px;" type="button" onclick="submitJoinForm()">Join Now</button>
</form>
로그인 form도 각자 필요에 맞춰 추가, 수정
spring boot/thymeleaf 환경에서 spring security 활용하기
1. gradle dependency 추가
- build.gradle
dependencies {
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5'
}
2. html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.w3.org/1999/xhtml">
<header th:fragment="headerFragment">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container px-5">
<a class="navbar-brand" href="/">SURFMIE</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent"
aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item"><a th:attr="class=${#strings.equals(#httpServletRequest.requestURI, '/') || #strings.equals(#httpServletRequest.requestURI, '') == true ? 'active nav-link' : 'nav-link'}"
aria-current="page" href="/">HOME</a></li>s
<li class="nav-item"><a th:attr="class=${#strings.equals(#httpServletRequest.requestURI, '/login') == true ? 'active nav-link' : 'nav-link'}"
sec:authorize="isAnonymous()" th:href="@{/login}">LOGIN</a></li>
<li class="nav-item"><a th:attr="class=${#strings.equals(#httpServletRequest.requestURI, '/join') == true ? 'active nav-link' : 'nav-link'}"
sec:authorize="isAnonymous()" th:href="@{/join}">JOIN</a></li>
<li class="nav-item"><a th:attr="class=${#strings.equals(#httpServletRequest.requestURI, '/movie/mylist') == true ? 'active nav-link' : 'nav-link'}"
sec:authorize="isAuthenticated()" th:href="@{/movie/mylist}">MY LIST</a></li>
<li class="nav-item"><a th:attr="class=${#strings.equals(#httpServletRequest.requestURI, '/mypage') == true ? 'active nav-link' : 'nav-link'}"
sec:authorize="isAuthenticated()" th:href="@{/mypage}">
<span sec:authentication="principal.nickname">Username</span></a></li>
<li class="nav-item"><a th:attr="class=${#strings.equals(#httpServletRequest.requestURI, '/logout') == true ? 'active nav-link' : 'nav-link'}"
sec:authorize="isAuthenticated()" th:href="@{/logout}">LOGOUT</a></li>
</ul>
</div>
</div>
</nav>
</header>
</html>
sec:authorize를 통해 권한을 검사
isAuthenticated() : 권한이 있는 사용자, 로그인 인증을 받은 사용자
isAnonymous() : 익명 사용자, 로그인 하지 않은 사용자
hasRole('') : 특정 권한을 가진 사용자, Security Config에서 설정한 hasRole
hasRole('', '') : hasRole과 같은 역할을 함, 여러 권한을 동시에 설정 가능
sec:authentication="principal.nickname"
로그인 인증을 받은 사용자만 사용
nickname을 가져와서 출력(여기서 nickname은 UserCustom에서 추가한 nickname)
reference
- Understand Spring Security Architecture and implement Spring Boot Security
https://www.javainuse.com/webseries/spring-security-jwt/chap3
- [SpringBoot] Spring Security란?
https://mangkyu.tistory.com/76
- [SpringBoot] Spring Security 처리 과정 및 구현 예제
https://mangkyu.tistory.com/77
- [Spring Security] Spring Security의 개념과 동작 과정
https://doozi0316.tistory.com/entry/Spring-Security-Spring-Security의-개념과-동작-과정
- [Spring Security] 스프링시큐리티의 기본 개념과 구조
- 스프링 시큐리티 기본 API및 Filter 이해
https://catsbi.oopy.io/c0a4f395-24b2-44e5-8eeb-275d19e2a536
'Programming > Spring' 카테고리의 다른 글
[Spring] RestTemplate (0) | 2021.12.29 |
---|---|
[Spring] Spring Data JPA (0) | 2021.12.28 |
[Spring] @Controller와 @RestController의 차이 (0) | 2021.10.20 |