본문 바로가기
Spring&Spring Boot

[Spring Boot] 스프링 시큐리티 - 패스워드 암호화 적용하기

by 피자보다 치킨 2022. 6. 29.

Spring Security 공식 문서

Spring Security

 

Spring Security

Spring Security is a framework that focuses on providing both authentication and authorization to Java applications. Like all Spring projects, the real power of Spring Security is found in how easily it can be extended to meet custom requirements

spring.io

Spring 기반의 Application의 보안을 위한 Spring framework

 

스프링 시큐리티의 PasswordEncoder를 이용하여 패스워드를 암호화 할 것이다.

 

Gradle 프로젝트를 기반으로 진행해보도록 하겠다.

 

1. 의존성 주입

build.gradle에 dependency 추가

Maven Repository: org.springframework.boot » spring-boot-starter-security (mvnrepository.com)

 

Maven Repository: org.springframework.boot » spring-boot-starter-security

Starter for using Spring Security VersionVulnerabilitiesRepositoryUsagesDate2.7.x2.7.1Central10Jun, 20222.7.0Central59May, 20222.6.x2.6.9Central1Jun, 20222.6.8Central140May, 20222.6.7Central48Apr, 20222.6.6Central90Mar, 20222.6.5Central20Mar, 20222.6.4Cent

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)

 

[Spring Security] 스프링시큐리티 설정값들의 역할과 설정방법(2)

스프링시큐리티의 여러가지 설정값들의 역할과 설정방법을 상세히 알아봅니다. 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}";
    }
}

 

댓글