Spring Security/일반로그인

[Spring Security] 이메일 인증 회원가입 및 로그인 구현(2)

limwngur 2023. 8. 18. 21:15
728x90
해당 포스팅에서는
Spring Security를 이용한 Form-login을 진행할 예정이고요. token방식은 이미 이전에 소셜 로그인하면서 다루어 보았기 때문에 Session방식 진행을 경험해보고자 했습니다.

※[Spring Security] 이메일 인증 회원가입 및 로그인 구현(1)과 같은 프로젝트 입니다.

Member Entity는 이전(1)편의 포스팅 내용과 동일

Spring Security의 form-login을 사용하여 login 구현을 할 예정이므로, 따로 컨트롤러가 필요하지 않습니다.
해당 이유는 해당 게시글을 모두 읽은 뒤 알 수 있을 것입니다.
SecurityConfig

Spring Security란? 엔터프라이즈 애플리케이션에 대한 인증(Authentication) 및 인가(Authorization) 및 기타 보안 기능을 제공하는 java / java

더보기

/*import 내용*/

import BE.MyRoute.config.auth.PrincipalDetailsService;
import BE.MyRoute.member.controller.LogController;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
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.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true , prePostEnabled = true) // sercured 어노테이션 활성화 , preAuthorize+postAuthorize 어노테이션 활성화
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .csrf().disable();
        http
                .authorizeRequests()
                .antMatchers("/login/**").permitAll()
                .antMatchers("/v1/register").permitAll()
                .antMatchers("/v1/mail").permitAll()
                .antMatchers("/").permitAll()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .usernameParameter("email")
                .passwordParameter("password")
                .loginProcessingUrl("/login")
                ;
    }
}

antMatchers 같은 경우에는 전부 authenticated()해두었다가 필요에 의해서 하나 둘 추가하였다.

로그인과 회원가입 그리고 이메일 인증 및 검증의 경우 비회원도 사용가능해야 하므로, permitAll()해주었다.

그리고 "/"은 Security form login을 진행하면서 "/" 엔드포인트를 가진 GET요청을 하는 Controller가 없으면, 로그인이 실패하게 된다. 자세한 이야기는 추후 Security만 본격적으로 정리할 때 다루도록 할 예정.

 

.formlogin() : formlogin을 사용하겠다!

.usernameParameter : 원래 security를 통해 로그인을 할때, UserDetailsService를 상속받아 오버라이딩 하는 메소드인 loadByUsername의 파라미터를 봐도 알겠지만, 내가 로그인 할 때 사용하려는 email이 아니라, username이라는 변수명을 통해 요청한 유저의 정보를 가져온다. (username, password가 기본) 따라서, 이메일 로그인을 위해 기본값을 임의로 바꾸어 준 것이다.

.passwordParameter : 위 내용과 동일. 나의 비밀번호 변수명도 password이기에 안바꾸어 줘도 되지만, 그냥 명시적으로 적어놨다.

.loginProcessingUrl : 여기서 argument로 넣어준 /login이라는 엔드포인트로 Post요청이 올 시에 Security가 낚아채서 대신 로그인을 진행합니다. >따라서 Controller가 필요X

 

이때, loginProcessingUrl("/login")을 설정한 뒤, 
/login 주소로 요청이 오면 자동으로 UserDetailsService 타입으로 IoC되어 있는 loadUserByUsername함수가 실행이 됩니다.(그냥 규칙입니다.)

따라서 UserDetailsService를 구현한 구현체를 만들어주어야하고, loadUserByUsername(String username)에서 파라미터 값을 (String email)로 바꾸어주어야합니다. 왜냐하면, SecurityConfig에서 usernameParameter를 기본 값이 username이 아닌, email로 바꾸어 주었기 때문입니다. 백문이 불여일견 코드를 살펴봅시다.
그전에!! 
잠시 앞으로 구현할 UserDetails, UserDetailsService 인터페이스에 대해 잠시 이야기하고 넘어가려고 해요.

UserDetails란?

Spring Security에서 사용자의 정보를 담는 인터페이스.

우리가 UserDetails인터페이스를 구현하게 되면, spring security에서는 해당 인터페이스를 구현한 구현체 클래스를 사용자 정보로 인식한다. 그리고 이를 기반으로 인증 작업을 한다. 간단하게, UserDetails 인터페이스는 VO역할이다!! 로그인 요청을 하게 되면, Security가 로그인 과정을 진행하기 위해 필요한 사용자의 정보를 모두 담고 있는 이 UserDetails 구현체를 통해 인증 작업을 하게 되는 것이다.

 

userDetails 인터페이스 구현체를 만들면 아래와 같은 메소드들이 오버라이딩 된다.

또한, 회원정보에 대한 다른 정보(생년월일, 나이, 성별, ...)도 추가하고 Getter Setter를 만들어 사용해도 된다.

메소드 명 리턴 타입 설명
getAuthorities() Collection< ? extends GrantedAuthority> 계정이 가진 권한 목록을 리턴
getPassword() String 계정의 비밀번호 리턴
getUsername() String 계정의 이름을 리턴
isAccountNonExpired() boolean 계정이 만료되지 않았는지?(true:만료X)
isAccountNonLocked() boolean 계정이 잠겨있지 않았는지?(true:잠김X)
isCredentialNonExpired() boolean 비밀번호가 만료되지 않았는지?(true: 만료X)
isEnabled() boolean 계정 활성화 상태인지?(true: 활성화)

필자는 아래와 같이 UserDetails 인터페이스를 구현하였다.

이 때, UserDetails는 우리가 만든 User객체를 가지고 있지는 않는다.(OAuth2User도 마찬가지) 따라서, UserDetails 구현체 클래스를 하나 만들고 이 클래스 안에 User(Member)객체를 품도록 하였다.

※OAuth2User에 관련된 오버라이딩 메소드는 일반 로그인만 구현할 것이라면 필요없다.

package com.example.security1.config.auth;

// 시큐리티가 /login 주소 요청이 오면 낚아채서 로그인을 진행시킨다.
// 로그인 진행이 완료가 되면 시큐리티 session을 만들어줍니다.(Security ContextHolder;시큐리티 자신만의 세션 공간)
// 시큐리티 세션에 들어갈 수 있는 오브젝트 => Authentication 타입 객체
// Authentication 안에는 User정보가 있어야 됨.
// User오브젝트타입 => UserDetails 타입 객체

// Security Session(여기 세션정보 저장) => Authentication => UserDetails(PrincipalDetails)

import com.example.security1.model.User;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Map;

/*얘는 왜 메모리에 등록을 하지 않을까? => 나중에 new해서 강제로 띄울 것임*/
@Data
@NoArgsConstructor
@Slf4j
public class PrincipalDetails implements UserDetails, OAuth2User {
    /*
    일반로그인 -> UserDetails
    OAuth로그인 -> OAuth2User 타입이 Authentication에 들어간다.

    따라서, 이 두 가지는 PrincipalDetils 타입만 찾으면 되도록 implementation해 묶어준다.
     */
    private User user; //콤포지션(유저 정보는 User가 가지고 있음)
    private Map<String,Object> attributes;

    // 일반 로그인 생성자
    public PrincipalDetails(User user) {
        this.user = user;
    }

    // OAuth 로그인 생성자
    public PrincipalDetails(User user, Map<String, Object> attributes) {
        this.user = user;
        this.attributes = attributes;
    }

    @Override
    public Map<String, Object> getAttributes() {
        return attributes;
    }

    //해당 User의 권한을 리턴하는 곳!!!
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> collect = new ArrayList<>();
        collect.add(new GrantedAuthority() {
            @Override
            public String getAuthority() {
                return user.getRole();
            }
        });

        return collect;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    //사용자의 계정이 만료되었는지 여부
    // true: 만료 X
    // false: 만료 O. 더 이상 계정이 유효하지 않음
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    // 계정이 잠겨 있는지 여부를 반환
    // true: unLocked
    // false: locked
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //사용자의 자격 증명(비밀번호)가 만료되었는지 여부 반환
    // true: 만료 X
    // false: 만료 O 더 이상 자격 유효 하지 않음
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    // 사용자 계정이 활성화(사용 가능?)여부를 반환
    // true: 활성화 상태
    // false: 비활성화 상태
    @Override
    public boolean isEnabled() {

        /*false 리턴 사용 예시: 휴먼 계정*/
        // 우리 사이트!!1년동안 회원이 로그인을 안하면!! 휴먼 계정으로 하기로 함.
        // 현재시간 - 로긴시간 => 1년 초과하면 return false;
//        user.getLoginDate();


        return true;
    }

    @Override
    public String getName() {
        return null;
    }
}

UserDetailsService란?

사용자 정보를 담을 객체(UserDetails 구현체)를 만들었으니, DB에서 유저 정보를 직접 가져오는 인터페이스가 바로 UserDetailsService이다.

 

UserDetailsService에는 DB에서 유저 정보를 불러오는 중요한 메소드가 있다. 바로 loadUserByUsername()이다.

이 메소드에서 form-data로 입력한 정보를 통해 인자로 넘어온 email을 파라미터가 받아서 repository에서 유저를 불러오게 한다. 

또 여기서 알아두어야할 지식이 있다.
Spring Security는 서버 고유의 Session안에 자기만의 Security Session을 가지고 있다. 이 Security Session안에 들어갈 수 있는 타입은 Authentication타입이다. 또한, Authentication타입 안에는 UserDetails와 OAuth2User 타입이 들어갈 수 있다!!

Security Session(Authentication(UserDetails)이런식으로 최종적으로 시큐리티 세션에 들어감.

위 박스의 내용을 왜 언급하였냐면,

UserDetails 리턴 타입을 가진 loadUserByUsername이 동작하면서 이 녀석이 알아서 (아래 코드를 참고) return UserDetails(UserDetailsService))가 될 때, 리턴된 UserDetails 타입이 Authentication에 들어가게 된다. 그러면서, Security Session내부에 해당 Authentication을 넣어준다. 

이 모든 것을 loadUserByUsername이 해주는 것이다!! 굉장히 편리하다. 이래서 스프링 스프링 하나보다.

리턴할 때, UserDetails타입을 new해서 리턴하므로, UserDetails 구현체에 해당 파라미터를 받는 생성자를 만들어 주어야한다.

 
@Service
@Slf4j
public class PrincipalDetailsService implements UserDetailsService {

    @Autowired
    private MemberRepository memberRepository;

    // TODO: 여기서 member를 못 찾고 있음
    @Override
    public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
        Member memberEntity = memberRepository.findByEmail(email);
        log.info("조건문 진입 전: {}", memberEntity.getClass());
        if(email != null){
            log.info("조건문 진입 후: {}", memberEntity);
            log.info(new PrincipalDetails(memberEntity).toString());

            return new PrincipalDetails(memberEntity);
        }

        return null;
    }
}

[Postman TEST]

.loginProcessingUrl("/login") // /login 주소가 호출이 되면 시큐리티가 낚아채서 대신 로그인을 진행해줍니다. -> 컨트롤러에 /login 안만들어도 시큐리티가 대신 함

위와 같이 로그인 프로세싱 url을 설정해 두었으므로, postman에서 해당 엔드포인트로 POST요청을 합니다.

 

DB에 없는 user로 로그인 시 에러(예외처리 안함.)

앗! 에러가 뜨네요. Please Sign In.. 

하지만, 200OK가 뜨죠? 이건 제가 SecurityConfig에서 성공했을 때와 실패했을 때 실행해주는 Handler를 구현해놓지 않았기 때문이니까. 가볍게 로그인 안됐다고 생각하고 넘어갑니다.

 

그럼, 회원가입을 먼저 할게요.(회원가입은 간단히 빌더패턴으로 구현하였기에 따로 기재X)

회원가입 성공!(DB에 저장됐는지는 굳이 기재 안하겠습니다..)

다시 로그인을 요청을 날려보아요.

Security formLogin 성공

성공입니다!

※혹시나 하는 마음에 - 해당 로그인은 form login이므로, raw->json이 아니라 form-data로 입력해야 값이 잘 넘어갑니다.

 


이전에 JWT+Security+OAuth2.0 로그인을 구현해보았기 때문에 몇 시간만에 뚝딱- 공부하고 만들 줄 알았다.
하지만, 꽤 많은 에러들을 마주했고 비례하여 학습시간도 증가하였다.

시간이 날 때, Spring Security의 이론을 근본부터 공부하고 정리하는 시간을 가지고 싶다. 이 다음 프로젝트에서는 "로그인 구현 누가하죠?" 라는 질문에 자신 있게 "금방하니까 제가 할게요." 라는 말을 건네어 든든한 팀원이 되어주고 싶다.

댓글로 질문을 남기면, 맨날 노트북 앞에 있으니 답변 남겨드리겠습니다!
728x90