Spring Security 공식 문서
Spring 기반의 Application의 보안을 위한 Spring framework
스프링 시큐리티의 PasswordEncoder를 이용하여 패스워드를 암호화 할 것이다.
Gradle 프로젝트를 기반으로 진행해보도록 하겠다.
1. 의존성 주입
build.gradle에 dependency 추가
Maven Repository: org.springframework.boot » spring-boot-starter-security (mvnrepository.com)
dependencies {
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-security', version: '2.4.5' /* 스프링 시큐리티 */
}
2. Config 설정
/*
* Copyright 2011-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.security.crypto.password;
/**
* Service interface for encoding passwords.
*
* The preferred implementation is {@code BCryptPasswordEncoder}.
*
* @author Keith Donald
*/
public interface PasswordEncoder {
/**
* Encode the raw password. Generally, a good encoding algorithm applies a SHA-1 or
* greater hash combined with an 8-byte or greater randomly generated salt.
*/
String encode(CharSequence rawPassword);
/**
* Verify the encoded password obtained from storage matches the submitted raw
* password after it too is encoded. Returns true if the passwords match, false if
* they do not. The stored password itself is never decoded.
* @param rawPassword the raw password to encode and match
* @param encodedPassword the encoded password from storage to compare with
* @return true if the raw password, after encoding, matches the encoded password from
* storage
*/
boolean matches(CharSequence rawPassword, String encodedPassword);
/**
* Returns true if the encoded password should be encoded again for better security,
* else false. The default implementation always returns false.
* @param encodedPassword the encoded password to check
* @return true if the encoded password should be encoded again for better security,
* else false.
*/
default boolean upgradeEncoding(String encodedPassword) {
return false;
}
}
PasswordEncoder는 스프링 시큐리티의 인터페이스 객체이다.
PasswordEncoder는 비밀번호를 암호화하는 역할로, 구현체는 이 암호화를 어떻게 할지, 암호화 알고리즘에 해당한다.
그래서 PasswordEncoder의 구현체를 대입해주고 이를 스프링 빈으로 등록하는 과정이 필요하다.
기존적인 설정들을 disable하는 Config 객체는 WebSecurityConfigurerAdapter를 상속받아 configure()를 구현한다.
/**
* Spring Security 사용을 위한 Configuration Class를 작성하기 위해서
* WebSecurityConfigurerAdapter를 상속하여 클래스를 생성하고
* @Configuration 애노테이션 대신 @EnableWebSecurity 애노테이션을 추가한다.
*/
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* PasswordEncoder를 Bean으로 등록
*/
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable() // post 방식으로 값을 전송할 때 token을 사용해야하는 보안 설정을 해제
.authorizeRequests()
.antMatchers("/css/**", "/js/**", "/*.ico", "/error", "/",
"/webapp/**", "/upload/**", "/product/**",
"/login", "/logout", "/members/**").permitAll()
.anyRequest().authenticated();
}
}
(클래스명은 각자 다르게 지어도 됩니다.)
BcryptPasswordEncoder는 BCrypt라는 해시 함수를 이용하여 패스워드를 암호화하는 구현체이다.
configure(http:HttpSecurity):void 오버라이드해야합니다.
Spring Security의 설정은 HttpSecurity로 한다.
@Override
protected void configure(HttpSecurity http) throws Exception {}
.antMatchers
.antMatchers("/css/**", "/js/**", "/*.ico", "/error", "/").permitAll()
특정 리소스에 대해서 권한을 설정한다.
뒤에 붙은 permitAll()은 antMatchers에서 설정한 URL의 접근을 인증없이 허용한다는 뜻이다.
.anyRequest
.anyRequest().authenticated()
이 옵션은 모든 리소스가 인증을 해야만 접근이 허용된다는 뜻이다.
스프링 시큐리티 설정값 참고한 블로그
[Spring Security] 스프링시큐리티 설정값들의 역할과 설정방법(2) (kimchanjung.github.io)
3. 회원가입/로그인 구현
MemberEntity
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member extends BaseEntity {
@Id @GeneratedValue
@Column(name = "member_id")
private Long id; //시퀀스
private String userId; //이메일(아이디)
@Column(length = 10)
private String nickname; //닉네임
private String pass;
private String username;
private int hp;
@Embedded
private Address address;
public Member(String userId, String nickname, String pass, String username, int hp, Address address) {
this.userId = userId;
this.nickname = nickname;
this.pass = pass;
this.username = username;
this.hp = hp;
this.address = address;
}
// 회원정보 수정메서드
public void change(String nickname, String username, int hp, Address address) {
this.nickname = nickname;
this.username = username;
this.hp = hp;
this.address = address;
}
//비밀번호 수정메서드
public void passwordChange(String pass) {
this.pass = pass;
}
/**
* 비밀번호를 암호화하는 메서드
*/
public Member hashPassword(PasswordEncoder passwordEncoder) {
this.pass = passwordEncoder.encode(this.pass);
return this;
}
}
MemberEntity에 PasswordEncoder를 사용하여 password를 인코딩하였다.
MemberRepository
@Repository
public class MemberRepository {
@PersistenceContext //스프링 제공
private EntityManager em;
// 회원 저장
public void save(Member member) {
em.persist(member);
}
//회원 단건 조회
public Member findOneMember(Long memberId) {
return em.find(Member.class, memberId);
}
//회원 전체 조회
public List<Member> findAllMembers() {
return em.createQuery("select m from Member m", Member.class)
.getResultList();
}
}
JPA의 편리한 CRUD
1. 회원가입
MemberService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
/**
* 회원가입
*/
@Transactional
public Long join(Member member) {
validateDuplicateMember(member);
member.hashPassword(passwordEncoder); //스프링 시큐리티(암호화)
memberRepository.save(member);
return member.getId();
}
/**
* 중복 아이디 검증 메서드
*/
private void validateDuplicateMember(Member member) {
List<Member> findMembers = memberRepository.findByUserId(member.getUserId());
if (findMembers.size() > 0) {
throw new IllegalStateException("이미 존재하는 회원입니다.");
}
}
}
MemberService에서 회원가입 진행시 join메서드에서
생성자를 통해 주입받은 PasswordEncoder passwordEncoder를 사용하여 비밀번호 해싱 후
Repository로 DB에 저장할 수 있도록 하였다.
MemberController
private final MemberService memberService;
private final LoginService loginService;
@GetMapping("/join")
public String createForm(Model model) {
model.addAttribute("memberForm", new CreateMemberForm());
return "members/joinMemberForm";
}
@PostMapping("/join")
public String join(@Valid @ModelAttribute("memberForm") CreateMemberForm form, BindingResult result) { //form 안에 에러가 있으면 튕겨내지말고 result에 담음
if (!form.getPassword().equals(form.getPasswordCheck())) {
result.reject("passwordFail", "비밀번호가 일치하지 않습니다.");
}
if (result.hasErrors()) { //만약에 result 안에 에러가 있으면
return "members/joinMemberForm"; //다시 폼으로 이동
}
Address address = new Address(form.getAddress(), form.getDetailedAddress());
Member member = new Member(form.getUserId(), form.getNickname(), form.getPassword(),
form.getUsername(), form.getHp(), address);
memberService.join(member);
return "redirect:/";
}
컨트롤러에서는 "/join"에 POST요청이 들어오면
기본적인 Validation 후 memberService.join()을 통해 회원가입이 진행될 수 있도록 해주었다.
2. 회원 로그인
LoginService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
/**
* 로그인
*/
public Member login(String userId, String password) {
Optional<Member> findMemberOptional = memberRepository.findByloginId(userId);
//아이디 조회해서 해당 아이디 정보가 있을 경우( 없으면 null 반환받음)
if (!findMemberOptional.isPresent()) {
return null;
}
Member member = findMemberOptional.get();
/**
* 비밀번호 확인 (스프링 시큐리티)
* password 암호화 이전의 비밀번호
* member.getPass() 암호화에 사용된 클래스
* @return true/ false
*/
if (passwordEncoder.matches(password, member.getPass())) {
return member;
} else {
return null; //비밀번호가 일치하지 않을 경우 null 반환
}
}
login메소드는 회원 아이디와 비밀번호를 체크하는 메소드이다.
passwordEncoder.matches():
matches()는 내부에서 사용가자 입력한 평문 패스워드와 db에 암호화되어 저장된 패스워드가 서로 대칭되는지에 대한 알고리즘을 구현하고 있다.
먼저 아이디를 조회한 후 입력받은 값의 아이디가 있는지 확인 후 (없으면 null반환)
비밀번호가 일치하면 memberEntitiy를, 비밀번호가 일치하지 않으면 null을 반환하도록 하였다.
LonginController
@Slf4j
@Controller
@RequiredArgsConstructor
public class LoginController {
public static final String LOGIN_MEMBER = "loginMember";
private final LoginService loginService;
@GetMapping("/login")
public String loginForm(@ModelAttribute("form")LoginDto form) {
return "/members/login";
}
@PostMapping("/login")
public String login(@Valid @ModelAttribute("form") LoginDto form,
BindingResult result,
@RequestParam(defaultValue = "/") String redirectURL,
HttpServletRequest request) {
if (result.hasErrors()) {
return "/members/login";
}
Member loginMember = loginService.login(form.getUserId(), form.getPassword());
//로그인 실패시 (null)
if (loginMember == null) {
result.reject("loginFail", "아이디 또는 비밀번호가 일치하지 않습니다");
return "/members/login";
}
//로그인 성공처리
Address address = loginMember.getAddress();
MemberDto.SessionMemberData memberData = new MemberDto.SessionMemberData(
loginMember.getId(), loginMember.getUserId(), loginMember.getNickname(), loginMember.getUsername());
//기존 세션이 있으면 세션을 반환, 없으면 새로운 세션을 생성
HttpSession session = request.getSession();
//세션에 로그인 회원 정보를 보관 (쿠키에 key: JSESSIONID , value: UUID 로 들어감)
session.setAttribute(LOGIN_MEMBER, memberData);
return "redirect:" + redirectURL;
}
}
컨트롤러에서는 로그인 성공시 세션에 로그인 회원을 저장하고 로그인 상태 유지를 할 수 있도록 하였다.
3. 비밀번호 변경
LoginService
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class LoginService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
/**
* 로그인
*/
public Member login(String userId, String password) {
Optional<Member> findMemberOptional = memberRepository.findByloginId(userId);
//아이디 조회해서 해당 아이디 정보가 있을 경우( 없으면 null 반환받음)
if (!findMemberOptional.isPresent()) {
return null;
}
Member member = findMemberOptional.get();
if (passwordEncoder.matches(password, member.getPass())) {
return member;
} else {
return null; //비밀번호가 일치하지 않을 경우 null 반환
}
/**
* 비밀번호 체크 (비밀번호 수정시 사용)
*/
public Member passwordCheck(Long memberId, String password) {
Member member = memberRepository.findOneMember(Long.valueOf(memberId));
/**
* 비밀번호 확인 (스프링 시큐리티)
* password 암호화 이전의 비밀번호
* member.getPass() 암호화에 사용된 클래스
* @return passwordEncoder.matches = true/ false
*/
if (passwordEncoder.matches(password, member.getPass())) {
return member;
} else {
return null; //비밀번호가 일치하지 않을 경우 null 반환
}
}
}
passwordCheck 메서드를 통해 비밀번호 수정전 현재 비밀번호를 입력받아서 한번 더 체크하기
MemberService
@Servic
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
/**
* 회원가입
*/
@Transactional
public Long join(Member member) {
validateDuplicateMember(member);
member.hashPassword(passwordEncoder); //스프링 시큐리티(암호화)
memberRepository.save(member);
return member.getId();
}
/**
* 중복 아이디 검증 메서드
*/
private void validateDuplicateMember(Member member) {
List<Member> findMembers = memberRepository.findByUserId(member.getUserId());
/* if (!findMembers.isEmpty()) { //isEmpty(): 문자열 길이가 0일 경우 true 반환, 여기서는 !isEmpty: 값이 있다면
throw new IllegalStateException("이미 존재하는 회원입니다.");
}*/
if (findMembers.size() > 0) { //이 코드가 더 최적화일 것 같다.
throw new IllegalStateException("이미 존재하는 회원입니다.");
}
}
/**
* 회원 전체 조회
*/
public List<Member> findMembers() {
return memberRepository.findAllMembers();
}
/**
* 회원 단건 조회
*/
public Member findOneMember(Long memberId) {
return memberRepository.findOneMember(memberId);
}
/**
* 회원 정보 수정
*/
@Transactional
public void editInformation(Long memberId, UpdateMemberForm form) {
Member findMember = memberRepository.findOneMember(memberId);
Address address = new Address(form.getAddress(), form.getDetailedAddress());
findMember.change(form.getNickname(), form.getUsername(),form.getHp(), address);
}
/**
* 비밀번호 수정
*/
@Transactional
public void editPassword(Long memberId, UpdateUserPassForm form) {
Member findMember = memberRepository.findOneMember(memberId);
findMember.passwordChange(form.getEditYourPassword());
findMember.hashPassword(passwordEncoder); //시큐리티 암호화
}
}
JPA 변경 감지(Dirty Checking)을 활용하여
- entity를 직접 꺼내(memberRepository.findOneMember(memberId)),
- 변경된 비밀번호넣은 후(findMember.passwordChange(password))
- 암호화 시킨 값으로 수정한다.(findMember.hashpassword(passwordEncoder))
//비밀번호 수정메서드
public void passwordChange(String pass) {
this.pass = pass;
}
/**
* 비밀번호를 암호화
* @param passwordEncoder
* @return
*/
public Member hashPassword(PasswordEncoder passwordEncoder) {
this.pass = passwordEncoder.encode(this.pass);
return this;
}
MemberController
@Slf4j
@Controller
@RequiredArgsConstructor
@RequestMapping("/members")
public class MemberController {
private final MemberService memberService;
private final LoginService loginService;
@GetMapping("/join")
public String createForm(Model model) {
model.addAttribute("memberForm", new CreateMemberForm());
return "members/joinMemberForm";
}
@PostMapping("/join")
public String join(@Valid @ModelAttribute("memberForm") CreateMemberForm form, BindingResult result) { //form 안에 에러가 있으면 튕겨내지말고 result에 담음
if (!form.getPassword().equals(form.getPasswordCheck())) {
result.reject("passwordFail", "비밀번호가 일치하지 않습니다.");
}
if (result.hasErrors()) { //만약에 result 안에 에러가 있으면
return "members/joinMemberForm"; //다시 폼으로 이동
}
Address address = new Address(form.getAddress(), form.getDetailedAddress());
Member member = new Member(form.getUserId(), form.getNickname(), form.getPassword(),
form.getUsername(), form.getHp(), address);
memberService.join(member);
return "redirect:/";
}
/**
* 비밀번호 수정
*/
@GetMapping("/{memberId}/editPassword")
public String editPasswordForm(@PathVariable("memberId") Long memberId, Model model) {
model.addAttribute("passwordForm", new UpdateUserPassForm());
return "members/updatePasswordForm";
}
@PostMapping("/{memberId}/editPassword")
public String editPassword(@PathVariable Long memberId,
@Valid @ModelAttribute("passwordForm") UpdateUserPassForm form,
BindingResult result,
RedirectAttributes redirectAttributes) {
// 현재 비밀번호 일치 확인
Member member = loginService.passwordCheck(memberId, form.getPass());
if (member == null) {
result.reject("passwordFail", "비밀번호가 일치하지 않습니다.");
return "members/updatePasswordForm";
}
// 변경 비밀번호 (재확인 비밀번호) 일치 확인
if (!form.getEditYourPassword().equals(form.getEditPasswordCheck())) {
result.reject("passwordFail2", "변경할 비밀번호가 일치하지 않습니다.");
}
if (result.hasErrors()) {
return "members/updatePasswordForm";
}
memberService.editPassword(memberId, form);
redirectAttributes.addAttribute("memberId", memberId);
return "redirect:/members/myPage/{memberId}";
}
}
'Spring&Spring Boot' 카테고리의 다른 글
[Spring Boot] Spring MultipartFile 파일업로드 구현하기 (0) | 2022.07.07 |
---|---|
Junit5 에서 Exception 테스트 (Junit4 @Test(expected = exception) (0) | 2022.05.31 |
JSP와 Thymeleaf (차이점, 동작 원리) (0) | 2022.04.01 |
스프링 컨테이너(BeanFactory, ApplicationContext) (0) | 2022.03.19 |
댓글