본문 바로가기
Project/ArtTab

[spring-security]BCryptPasswordEncoder를 통한 Login/Signup 구현

by 우기37 2023. 10. 3.

## 목차

1) Spring Security란?

2) BCryptPasswordEncoder란?

3) 프로젝트활용

4) 어려웠던점

 

 

 

#1 Spring Security란?

 

Spring Security는 강력하고 사용자 정의가 가능한 인증 및 액세스 제어 프레임워크입니다. 이는 Spring 기반 애플리케이션 보안을 위한 사실상의 표준입니다.

 

Spring Security는 Java 애플리케이션에 인증과 권한 부여를 모두 제공하는 데 중점을 두며 Filter 흐름에 따라 처리하는 프레임워크입니다. 모든 Spring 프로젝트와 마찬가지로 Spring Security의 진정한 힘은 사용자 정의 요구 사항을 충족하기 위해 얼마나 쉽게 확장할 수 있는지에 있습니다.

 

Filter는 Dispatcher Servlet으로 가기 전에 적용도므로 가장 먼저 URL 요청을 받지만, Interceptor는 Dispatcher와 Controller 사이에 위치한다는 점에서 적용 시기의 차이가 있습니다.

 

Spring Security의 서블릿 지원은 서블릿 필터를 기반으로 하므로 일반적으로 필터의 역할을 먼저 살펴보는 것이 도움이 됩니다.

아키텍쳐로는 아래와 같으며, 단일 HTTP 요청에 대한 일반적인 핸들러 계층을 보여줍니다.

이미지출처 : https://docs.spring.io/spring-security/reference/servlet/architecture.html

 

클라이언트는 애플리케이션에 요청을 보내고 컨테이너는 요청 URI의 경로를 기반으로 인스턴스를 FilterChain포함 Filter하고 Servlet처리해야 하는 객체를 생성합니다. HttpServletRequestSpring MVC 애플리케이션에서 는 Servlet의 인스턴스입니다. DispatcherServlet. 최대로 Servlet단일 HttpServletRequest 및 HttpServletResponse. 그러나 다음과 같은 경우에는 둘 이상을 Filter사용할 수 있습니다.

  • Filter다운스트림 인스턴스 또는 호출이 방지 Servlet됩니다. 이 경우 는 Filter일반적으로 HttpServletResponse.
  • 다운스트림 인스턴스 및 에서 사용되는 HttpServletRequest또는 를 수정합니다 .HttpServletResponseFilterServlet 의 힘은 그 안으로 전달되는 것에서 Filter로 나옵니다 .FilterChain

 

주요 기능과 개념:

  1. 인증 (Authentication):
    • 사용자가 시스템에 로그인하거나 인증하는 과정을 다룹니다.
    • 주요 인증 방식으로는 폼 기반 인증, HTTP 기반 인증, LDAP, OAuth, OpenID 등이 있습니다.
  2. 권한 부여 (Authorization):
    • 인증된 사용자에 대한 권한을 관리하고, 접근 권한을 결정합니다.
    • 권한은 롤(Role)이나 권한(Authority)으로 나타내며, 특정 리소스에 대한 접근을 허용하거나 거부할 수 있습니다.
  3. 접근 제어 (Access Control):
    • 인증된 사용자가 특정 리소스에 접근할 때, Spring Security는 권한을 기반으로 접근을 허용하거나 거부합니다.
    • URL 기반 접근 제어, 메소드 기반 접근 제어 등을 지원합니다.
  4. 세션 관리 (Session Management):
    • 세션을 관리하고 세션 공격에 대비하여 세션을 보호합니다.
  5. 암호화 (Cryptography):
    • 사용자의 비밀번호 및 기타 민감한 데이터를 암호화하여 보호합니다.
  6. CSRF 공격 방어 (CSRF Protection):
    • Cross-Site Request Forgery (CSRF) 공격에 대비하여 요청에 토큰을 추가하여 보안을 강화합니다.
  7. 사용자 정의 필터 (Custom Filters):
    • Spring Security에서 제공하는 여러 종류의 필터를 이용하여 요청 처리 중에 사용자 정의 보안 로직을 적용할 수 있습니다.

 

 

 

#2 BCryptPasswordEncoder란?

이미지출처 : https://velog.io/@parkirae/Spring-Security-BCryptPasswordEncoder

 

① 비밀번호를 암호화하는 방법입니다.
② 같은 값을 인코딩하더라도 인코딩 할 때마다 결과가 다릅니다.
③ 암호화할 때 랜덤하게 의미 없는 값을 붙여 결과를 생성합니다.

 

- BCryptPasswordEncoder는 BCrypt 해싱 함수(BCrypt hashing function)를 사용해서 비밀번호를 인코딩해주는 메서드와 사용자의 의해 제출된 비밀번호와 저장소에 저장되어 있는 비밀번호의 일치 여부를 확인해주는 메서드를 제공합니다.

- PasswordEncoder 인터페이스를 구현한 클래스입니다.

- 생성자의 인자 값(verstion, strength, SecureRandom instance)을 통해서 해시의 강도를 조절할 수 있습니다.

BCryptPasswordEncoder는 위에서 언급했듯이 비밀번호를 암호화하는 데 사용할 수 있는 메서드를 제공합니다. 기본적으로 웹 개발함에 있어서 사용자의 비밀번호를 데이터베이스에 저장하게 됩니다. 허가되지 않은 사용자가 접근하지 못하도록 기본적인 보안이 되어 있을 것입니다. 하지만 기본적 보안이 되어 있더라도, 만약 그 보안이 뚫리게 된다면 비밀번호 데이터는 무방비하게  노출됩니다. 이런 경우를 대비해 BCryptPasswordEncoder에서 제공하는 메서드를 활용하여 비밀번호를 암호화 함으로써 비밀번호 데이터가 노출되더라도 확인하기 어렵도록 만들어 줄 수 있습니다.

 

 

 

#3 프로젝트활용

Spring Security의 주요 기능 중 하나인 BcryptPasswordEncoder를 사용하여 암호화를 사용하였으며, 사용한 이유로는 기존 mybatis에서 sha256을 통한 암호화를 하는데 DB에 암호화가 잘 저장되지 않고, .xml 파일에 mapping을 해줄 때 mapping이 잘 되지 않아 어려움이 많았습니다.

 

이번 프로젝트에서 signup과 login/logout기능 구현을 맡아 진행하면서 암호화를 spring security의 BcryptPasswordEncoder 메서드를 통해 코드로 구현이 가능하다는 것을 발견했고 기능을 사용했습니다.

 

 

먼저 생성자와 methods에 대해서 java docs에서 알아 보았으며, 그 메소드 기능들에 대해서 알아보았습니다.

 

encode(java.lang.CharSequence rawPassword)

  • 패스워드를 암호화해주는 메서드입니다. encde() 메서드는 SHA-1, 8바이트로 결합된 해쉬, 랜덤 하게 생성된 솔트(salt)를 지원합니다. 
  • 매개변수는 java.lang.CharSequence타입의 데이터를 입력해주면 됩니다. (CharSequence를 구현하고 있는 java.lang의 클래스는 String, StringBuffer, StringBuilder가 있습니다.)
  • 반환 타입은 String 타입입니다.
  • 똑같은 비밀번호를 해당 메서드를 통하여 인코딩하더라도 매번 다른 인코딩 된 문자열을 반환합니다.

 

matchers(java.lang.CharSequence rawPassword, java.lang.String encodePassword)

  • 제출된 인코딩 되지 않은 패스워드(일치 여부를 확인하고자 하는 패스워드)와 인코딩 된 패스워드의 일치 여부를 확인해줍니다.
  • 첫 번째 매개변수는 일치 여부를 확인하고자 하는 인코딩 되지 않은 패스워드를 두 번째 매개변수는 인코딩 된 패스워드를 입력합니다.
  • 반환 타입은 boolean입니다.

 

upgradeEncoding(java.lang.String encodePassword)

  • 더 나은 보안을 위해서 인코딩 된 암호를 다시 한번 더 인코딩해야 하는 경우에 사용합니다.
  • 매개변수는 인코딩 필요 여부를 확인하고자 하는 인코딩 된 패스워드(String 타입)를 입력합니다.
  • 반환 타입은 인코딩이 필요한 경우 true를, 필요하지 않은 경우는 false를 입력합니다.
  • 기본 구현에는 항상 flase를 반환합니다.
  • encde() 메서드를 통해서 암 호환된 패스워드들은 upgradeEncoding()을 사용했을 때 모드 기본적으로 false를 반환합니다.
  • 따라서 개인적으로 생각해보았을 때 해당 메서드는 오버라이딩하여 더 강력한 해시를 해야 할지의 기준을 정한 뒤 로직 처리하여 활용할 수 있을 거 같습니다.

구현 코드는 다음과 같습니다.

SecurityConfig 를 아래와 같이 설정해주었습니다.

저의 경우는 spring security에서 암호화만 사용하기 위해서 csrf와 logout을 disable해서 비활성화를 해주었고, @Bean 객체로 주입하여 FilterChain을 이용하여 http 요청을 해주었습니다.

package arttab.server.config;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Slf4j
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.headers().frameOptions().sameOrigin(); //x-frame-options 동일 출처일경우만
        http.csrf().disable(); //CSRF Token 비활성화

        http.authorizeRequests().anyRequest().permitAll();
        http.logout().disable();
        //        http.apply(filterConfig);
        return http.build();
    }
    @Bean
    public PasswordEncoder passwordEncoder(){
        return PasswordEncoderFactories
                .createDelegatingPasswordEncoder();
    }
}

 

AuthController는 아래와 같습니다.

spring container에 등록한 Bean에 의존성을 주입하기 위해 PasswordEncoder를 @Autowired 하였습니다.

그리고 loginuser에 담긴 사용자가 입력한 pwd와 Encrypt Pwd가 같은지  matches 메서드를 통해 로직을 구현하였습니다.

logout의 경우는 config에서 http에 요청한 logout()을 disable 해놓았기 때문에 기존 session에 담긴 정보를 invalidate 하도록 하였습니다.

package arttab.server.controller;

import arttab.server.service.MemberService;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

import arttab.server.vo.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.ui.Model;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.CookieValue;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Slf4j
@Controller
public class AuthController {

    @Autowired
    private MemberService memberService;

    @Autowired
    private PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    @GetMapping("/form")
    public String form(@CookieValue(required = false) String memberEmail, Model model) {
        model.addAttribute("memberEmail", memberEmail);
        return "/form";
    }

    @GetMapping("/")
    public String index() {
        return "index";
    }

    @PostMapping("/login")
    public String login(
            @RequestParam("memberEmail") String memberEmail,
            @RequestParam("memberPwd") String memberPwd,
            HttpSession session,
            Model model,
            HttpServletResponse response) throws Exception {
        if (memberEmail != null && !memberEmail.isEmpty()) {  // null 또는 비어 있는지 확인
            Cookie cookie = new Cookie("memberEmail", memberEmail);
            response.addCookie(cookie);
        } else {
            Cookie cookie = new Cookie("memberEmail", "");  // 빈 문자열로 설정
            cookie.setMaxAge(0);
            response.addCookie(cookie);
        }
        Member loginUser = memberService.get(memberEmail, memberService.getEncryptPassword(memberPwd));
        log.error("사용자 정보는 있나? >>> {}", loginUser.toString());
        log.error("동일 한가 ? >>>{}}" , passwordEncoder.matches(memberPwd, loginUser.getMemberPwd()));
        if (!passwordEncoder.matches(memberPwd, loginUser.getMemberPwd())) {
            model.addAttribute("refresh", "2;url=/form");
            throw new Exception("회원 정보가 일치하지 않습니다.");
        }

        session.setAttribute("loginUser", loginUser);
        log.info("Call login");
        return "redirect:/";
    }

    @GetMapping("/logout")
    public String logout(HttpSession session) throws Exception {
        session.invalidate();
        log.info("Call logout");
        return "redirect:/";
    }
}

 

MemberService의 경우는 아래와 같이 구현하였습니다.

보시는것과 같이 password를 getEncryptPassword에 담겨 있는 것을 확인 할 수 있습니다.

package arttab.server.service;
import arttab.server.vo.Bid;
import arttab.server.vo.Member;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import org.springframework.stereotype.Service;

@Service
public interface MemberService {
    SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:sss");
    Date time = new Date();
    String localTime = format.format(time);

    List<Bid> getMemberBids(int memberNo) throws Exception;
    void updateMember(Member member) throws Exception;
    Member findBy(int memberNo) throws Exception; // 멤버 번호로 멤버 정보 조회!
    Member findByEmail(String memberEmail) throws Exception;
    String getEncryptPassword(String password);
    void add(Member member) throws Exception;
    List<Member> list() throws Exception;
    Member get(int memberNo) throws Exception;
    Member get(String memberEmail, String memberPwd) throws Exception;
    String findPasswordByEmail(String memberEmail) throws Exception;
    int delete(int memberNo) throws Exception;
}

 

DefaultMemberService

package arttab.server.service;

import arttab.server.dao.MemberDao;
import arttab.server.vo.Bid;
import arttab.server.vo.Member;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class DefaultMemberService implements MemberService {

  @Autowired
  private final MemberDao memberDao;
  @Autowired
  private final PasswordEncoder passwordEncoder;

  @Transactional
  @Override
  public void add(Member member) throws Exception {
    if (!member.getMemberName().equals("") && !member.getMemberEmail().equals("")) {
      // password는 암호화해서 DB에 저장
      String encryptedPassword = passwordEncoder.encode(member.getMemberPwd());
      member.setMemberPwd(encryptedPassword);  // 한 번만 암호화된 비밀번호를 저장
      member.setMemberDatetime(Timestamp.from(Instant.now().atZone(ZoneOffset.UTC).toInstant()));
      memberDao.insertMember(member);
    }
  }

  @Override
  public List<Member> list() throws Exception {
    return memberDao.findAll();
  }

  @Override
  public Member get(int memberNo) throws Exception {
    return memberDao.findBy(memberNo);
  }

  @Override
  public Member get(String memberEmail, String memberPwd) throws Exception {
    return memberDao.findByEmailAndPassword(memberEmail, memberPwd);
  }

  @Override
  public String getEncryptPassword(String password) {
    return passwordEncoder.encode(password);
  }

  @Transactional
  @Override
  public void updateMember(Member member) throws Exception {
    //member.setMemberPwd(passwordEncoder.encode(member.getMemberPwd()));
    memberDao.updateMember(member);
  }

  @Override
  public String findPasswordByEmail(String memberEmail) throws Exception {
    return memberDao.findPasswordByEmail(memberEmail);
  }

  @Transactional
  @Override
  public int delete(int memberNo) throws Exception {
    return memberDao.delete(memberNo);
  }

  @Override
  public List<Bid> getMemberBids(int memberNo) throws Exception {
    return memberDao.getMemberBids(memberNo);
  }

  @Override
  public Member findBy(int memberNo) throws Exception {
    return memberDao.findBy(memberNo);
  }
  @Override
  public Member findByEmail(String memberEmail) throws Exception {
    return memberDao.findByEmail(memberEmail);
  }

}

 

MemberController

package arttab.server.controller;

import arttab.server.service.MemberService;
import arttab.server.vo.Member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.multipart.MultipartFile;

@Slf4j
@Controller
public class MemberController {

    MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/signup")
    public void signUpForm() {

    }

    @PostMapping("/signup")
    public String signUp(Member member, MultipartFile photofile) throws  Exception {
        log.error("Email:[{}], Password:[{}]", member.getMemberEmail(),member.getMemberPwd());
        memberService.add(member);
        return "redirect:form";
    }

    @GetMapping("delete")
    public String delete(int memberNo) throws Exception {
        if (memberService.delete(memberNo) == 0) {
            throw new Exception("해당 번호의 회원이 없습니다.");
        } else {
            return "redirect:index";
        }
    }

    @GetMapping("{memberNo}")
    public String detail(@PathVariable int memberNo, Model model) throws Exception {
        model.addAttribute("member", memberService.get(memberNo));
        return "member/detail";
    }

    @GetMapping("list")
    public void list(Model model) throws Exception {
        model.addAttribute("list", memberService.list());
    }
}

 

ArtTab sign in

 

ArtTab sign up

 

#4 어려웠던점

구현하면서 가장 크게 2가지가 어려웠습니다.

 

첫 번째로는 config 설정이었습니다.

spring security에 대한 이해가 부족하다보니, config 설정을 어디까지 어떻게 해주어야 할지 감을 잡지 못했습니다. 분명 import해서 BCryptPasswordEncoder만 불러왔음에도 불구하고, 다른 security의 기능들과 충돌이 일어나 application server가 실행이 되지 않아 어려움을 느꼇습니다.

 

그래서 주변지인과 구글링을 통해 config를 아래와 같이 설정하였습니다.

http.headers().frameOptions().sameOrigin():
X-Frame-Options 헤더를 설정하여, 해당 웹 애플리케이션이 같은 출처에서만 볼 수 있도록 제한합니다.
즉, 웹 애플리케이션이 자신의 페이지 내에서만 자신의 페이지를 보여줄 수 있도록 설정합니다.

 

http.csrf().disable():
CSRF(Cross-Site Request Forgery) 토큰을 비활성화합니다.
CSRF 공격에 대비하여 토큰을 사용하는데, 여기서는 비활성화하였습니다.

 

http.authorizeRequests().anyRequest().permitAll():
모든 요청에 대해 접근을 허용합니다.
즉, 모든 요청에 대해 인증을 요구하지 않고 모든 사용자에게 접근을 허용합니다.

 

http.logout().disable():
로그아웃 관련 설정을 비활성화합니다.
로그아웃 기능을 사용하지 않도록 설정합니다.

 

두 번째로는 메서드 활용이었습니다.

matches 메서드에 대해서 사용법을 잘 알지 못해 사용자가 입력한 비밀번호와 loginuser에 저장된 비밀번호가 같게 해주려면 passwordEncoder.matches 메서드를 사용해야만 EcryptPassword에 대한 비밀번호가 동일한지 확인이 가능하다는 것을 지속적인 오류를 통해 알게 되었습니다.

 

 

github 주소 :

https://github.com/NC7-ArtTab/ArtTab

 

GitHub - NC7-ArtTab/ArtTab: ArtTab

ArtTab. Contribute to NC7-ArtTab/ArtTab development by creating an account on GitHub.

github.com

 

'Project > ArtTab' 카테고리의 다른 글

[kakao-developers]kakao api를 통한 KakaoLogin 구현  (0) 2023.10.03