[Spring Boot] (3) 로그인/회원가입 - 자사 회원가입 :: 음악학원 홈페이지 프로젝트

2020. 8. 14. 16:14Spring Boot

https://codecrafting.tistory.com/8 에서 이어지는 포스팅입니다.

 

[Spring Boot] (2) 로그인/회원가입 - Spring Security 설정 :: 음악학원 홈페이지 프로젝트

들어가기 전에 ... 스프링 시큐리티 Spring Security에 대해 스프링 시큐리티를 이용하면 개발시에 필요한 사용자의 인증, 권한, 보안 처리를 간단하지만 강력하게 구현 할 수 있습니다. 일반적인 웹

codecrafting.tistory.com

 

지난 포스팅에서는 Spring Security를 WebSecurityConfigurerAdapter 클래스를 확장하여 간단하게 구현했습니다. 오버라이딩 되는 메소드 중, configure(AuthenticationManagerBuiider) 메소드에서 사용자가 생성한 Service 클래스를 Spring Security에서 활용할 수 있도록 해당 서비스의 인스턴스를 userDetailsService() 메소드의 인자로 담았습니다.

    @Autowired
    MemberService memberService;

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    
    ...
    

	@Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(memberService).passwordEncoder(passwordEncoder());
    }

 

이제 회원가입과 로그인 과정에서 어떻게 데이터를 전달하고 이를 서비스에서 가공하여 Spring Security에서 사용하는지 보겠습니다.

 

 

 


회원가입 절차

1 .  /main/resources/templates/soriel_Sign_up

<form method="POST" class="register-form" id="register-form" th:action="@{/user/signup}">

<div class="form-group">
    <label for="name"><i class="zmdi zmdi-account material-icons-name"></i></label>
    <input type="text" name="name" id="name" placeholder="Your ID"/>
</div>

<div class="form-group">
    <label for="email"><i class="zmdi zmdi-email"></i></label>
    <input type="email"  name="email" id="email" placeholder="Your Email"/>
</div>
<div class="form-group">
    <label for="pass"><i class="zmdi zmdi-lock"></i></label>
    <input type="password" name="upwd" id="pass" placeholder="Password"/>
</div>
<div class="form-group">
    <label for="re-pass"><i class="zmdi zmdi-lock-outline"></i></label>
    <input type="password" name="re_upwd" id="re_pass" placeholder="Repeat your password"/>
</div>

<div class="form-group form-button">
	<input type="button" name="signup" id="signup" class="form-submit" value="Register"/>
</div>
</form>

회원가입 자체는 어려운지 않습니다. input 태그에 DTO에 맞는 변수를 설정하고 Controller에서 회원가입 요청을 처리할 URL로 action에 넣어줍니다. 필자는 닉네임과 이메일, 패스워드로 회원가입을 진행합니다. 이제 submit을 하면 컨트롤러의 execSignup()로 흐름이 전환되고, memberService의 joinUser(MemberDto memberdto) 메서드가 실행됩니다. 

 

 

2. /main/java/com/soriel/music/springboot/web/MemberController

package com.soriel.music.springboot.web;

import com.soriel.music.springboot.service.soriel.MemberService;
import com.soriel.music.springboot.web.dto.member.IntegrationDto;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
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.bind.annotation.ResponseBody;

import javax.validation.Valid;
import java.util.Map;

@Controller
@AllArgsConstructor
public class MemberController {

    private MemberService memberService;

    //회원가입 처리
    @PostMapping("/user/signup")
    public String execSignup(IntegrationDto memberDto) {

        memberService.joinUser(memberDto);
        return "redirect:/user/login_page";
    }
    
    
    //로그인 페이지
    @GetMapping("/user/login_page")
    public String login_page() {
        return "soriel_Login_page";
    }

    //회원가입 페이지
    @GetMapping("/user/sign_up_page")
    public String sign_up_page() {
        return "soriel_Sign_up";
    }
}

이제 회원가입 폼에서 넘어온 데이터들이 /user/signup url 로 접근함에 따라 IntegrationDto 필드 값에 세팅됩니다. IntegrationDto의 인스턴스 memberDto를 memberService의 joinUser() 메서드의 인자로 보내어 DB에 저장하도록 하겠습니다.

 

알아두면 좋아요

⭐️Spring은 @Controller 클래스에서 String값으로 return하는 메소드에 대해서, 해당 값에 /resource/templates/ 디렉토리 아래에서 해당 이름의 html 파일을 찾습니다. 따라서 예제의 경우 회원가입 시 GET방식의  /user/login_page 로 접근하는 경우 /resource/templates/soriel_Login_page.html 파일을 리턴합니다.

 

그런데 이상한 점이 보입니다. 컨트롤러에서 로그인 url을 지정하지 않았는데 어떻게 로그인을 할 수 있을까요? 이전 포스팅에 작성한 Spring Security에서 이미 로그인 요청에 대한 접근 url을 지정했기 때문입니다. 

    ...
                .and()
                    .formLogin()
                    .loginPage("/user/login_page")
                    .loginProcessingUrl("/user/login")
                    .defaultSuccessUrl("/")
                    .permitAll()
                    
    ...

 loginProcessingUrl 메소드의 인자로 "/user/login" 로의 접근을 로그인 시도라고 규정했기 때문에 따로 컨트롤러에 넣지 않아도 되는 것입니다. 이해가 되셨나요? 😝

 

3. /main/java/com/soriel/music/springboot/service/MemberService

import com.soriel.music.springboot.domain.member.IntegrationEntity;
import com.soriel.music.springboot.domain.member.IntegrationRepository;
import com.soriel.music.springboot.web.dto.member.CustomIntegrationDto;
import com.soriel.music.springboot.web.dto.member.IntegrationDto;
import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.*;

@Service
@AllArgsConstructor
public class MemberService implements UserDetailsService {

    private IntegrationRepository integrationRepository;

    public IntegrationEntity joinUser(IntegrationDto integrationDto) {
        // 비밀번호 암호화
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        integrationDto.setUpwd(passwordEncoder.encode(integrationDto.getUpwd()));

        return integrationRepository.save(integrationDto.toEntity());
    }
}

서비스단에서는 넘어오는 회원가입 객체의 비밀번호를 BCryptPasswordEncoder로 암호화 시킨 후 JPA에 저장하는 절차를 밟고 있습니다. 필자는 JPA를 한 단계 추상화 시킨 Repository의 구현체인 Spring Data JPA를 사용하고 있습니다. 관련 포스팅은 추후에 작성하도록 하겠습니다. 

 

4. /main/java/com/soriel/springboot/web/dto/IntegrationDto

import com.soriel.music.springboot.domain.Role;
import com.soriel.music.springboot.domain.member.IntegrationEntity;
import lombok.*;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
@NoArgsConstructor
public class IntegrationDto {
    //가제

    private Long id;
    private String name;
    private String email;
    private String upwd;
    private Role role;
    private LocalDateTime createdDate;
    private LocalDateTime modifiedDate;

    public IntegrationEntity toEntity() {
        return IntegrationEntity.builder()
                .id(id)
                .name(name)
                .email(email)
                .upwd(upwd)
                .role(Role.MEMBER)
                .build();
    }


    @Builder
    public IntegrationDto(Long id, String name, String email, String upwd, Role role) {
        this.id = id;
        this.name = name;
        this.email = email;
        this.upwd = upwd;
        this.role = role;
    }
}

DTO에서는 Entity에 값을 전달할 수 있는 toEntity() 메소드와 @Builder 어노테이션 기능을 이용한 필더패턴을 정의해서 사용하고 있습니다. 

 

5.  /main/java/com/soriel/music/springboot/domain/member/IntegrationEntity

import com.soriel.music.springboot.domain.BaseTimeEntity;
import com.soriel.music.springboot.domain.Role;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Getter
@Entity(name = "member")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class IntegrationEntity extends BaseTimeEntity {
    //가제

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 50, unique = true, nullable = false)
    private String email;

    @Column(nullable = false, unique = true)
    private String name;

    @Column(nullable = true, length = 200)
    private String upwd;

    @Enumerated(EnumType.STRING)
    @Column
    private Role role;

    @Builder
    public IntegrationEntity(Long id, String email, String name, String upwd, Role role) {
        this.id = id;
        this.email = email;
        this.name = name;
        this.upwd = upwd;
        this.role = role;
    }

    public String getRoleKey() {
        return this.role.getKey();
    }

    public IntegrationEntity update(String name, String email) {
        this.name = name;
        this.email = email;

        return this;
    }
}

 

IntegrationEntity는 데이터베이스에서 회원테이블을 담당하고 있는 Entity로, Column 이름과 속성을 정의하고 회원 정보 수정기능을 고려하여 닉네임과 이메일을 update할 수 있도록 관련 메소드를 생성합니다. 이때 필드 role의 경우에는 Enum key를 이용한 형식으로 값을 주입하고 있습니다.  

 

6. /java/main/com/soriel/music/springboot/domain/Role

package com.soriel.music.springboot.domain;

import lombok.AllArgsConstructor;
import lombok.Getter;

@AllArgsConstructor
@Getter
public enum Role {
    ADMIN("ROLE_ADMIN", "어드민"),
    MEMBER("ROLE_MEMBER", "일반 사용자");

    private String key;
    private String title;
}

Enum Key의 값으로 ROLE_** 형식으로 지정하는데, 이는 이전 포스팅에서 설정한 SecurityConfig 에서 사용 할 hasRole() 메소드를  사용하기 위함입니다.

...

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                    .antMatchers("/admin/**").hasRole("ADMIN")
                    .antMatchers("/user/myinfo").hasRole("MEMBER")
                    .antMatchers("/**").permitAll()
                    
...

 

왜 ROLE_** 형식으로 지정해야 하나요

ExpressionUrlAuthorizationConfigurer.class, hasRole() method

/**
* Shortcut for specifying URLs require a particular role. If you do not want to
* have "ROLE_" automatically inserted see {@link #hasAuthority(String)}.
*
* @param role the role to require (i.e. USER, ADMIN, etc). Note, it should not
* start with "ROLE_" as this is automatically inserted.
* @return the {@link ExpressionUrlAuthorizationConfigurer} for further
* customization
*/
public ExpressionInterceptUrlRegistry hasRole(String role) {
	return access(ExpressionUrlAuthorizationConfigurer.hasRole(role));
}

hasRole() 메소드는 "ROLE_" 로 시작하는 authority를 자동으로  Spring Security Filter 에 등록합니다. 만약 "ROLE_" 로 시작하는 문자열을 사용하고 싶지 않다면 hasAuthority() 메소드를 사용하면 됩니다.

 

JPA에 IntegrationEntity 객체를 save함에 따라 회원가입 절차는 모두 끝이 났습니다.

 

로그인 절차는 조금 더 복잡한데, 앞선 포스팅에서 설명했듯이 UsernamePasswordAuthenticationFilter를 거쳐 로그인이 성공적으로 이루어지고 나면 SecurityContext에 UsernamePasswordAuthentication 객체를 Authentication 객체와 함께 저장합니다. 인증이 완료되면 Session에 SecurityContext를 저장하고 Response 합니다.

 

다음 포스팅에서는 자사 로그인 서비스를 구현해보도록 하겠습니다.