~☆~ 우하하!!~ 개발블로그

[연재] SpringBoot diary - 역할(ROLE) 관리 기능 추가 본문

SpringBoot

[연재] SpringBoot diary - 역할(ROLE) 관리 기능 추가

iwoohaha 2024. 11. 15. 10:35
728x90
반응형

일기 프로그램에 무슨 역할이 필요있겠어? 개인이 로그인해서 일기를 작성하고, 내가 작성한 일기를 볼 수 있으면 되었지. 그런데, 가만 생각해보니 회원 관리 기능을 넣는다면 역할 관리가 필요하겠더라고.

권한과 역할은 엄밀히 말해서 다른거야. 이 포스트에서는 권한이 아닌 역할(ROLE)을 관리하는 방법에 대해서 알아보는거야.

그래서 이번 포스트에서는 diary 프로그램에 역할 관리 기능을 넣어보려고 해.

역할 관리 기반 구조 작성

기존의 사용자정보 저장 테이블인 member 는 아래와 같이 구성되어 있었어.


역할 관리를 하기 위해서는 역할 정보를 넣어주어야 하니까, 아래와 같은 스크립트를 이용해서 역할 컬럼(role)을 추가했어.

--PostgreSQL Query
ALTER TABLE public."member" ADD "role" varchar(255) NULL;
COMMENT ON COLUMN public."member"."role" IS '사용자 역할';


새로 추가한 role 컬럼에 들어갈 값의 형식은 ROLE_USER 이거나 ROLE_ADMIN 과 같아. ROLE_USER,ROLE_ADMIN 이 될 수도 있어.

이 컬럼 추가와 연결되어 수정해야 할 코드를 살펴볼께.

일단 member 테이블의 레코드 한 개 데이터를 저장하기 위한 도메인 클래스 Member 에 역할값을 저장하기 위한 필드를 추가해야겠지.

Member.java
@AllArgsConstructor
@Getter
@Setter
@NoArgsConstructor
public class Member {
    @NotBlank(message = "이메일 주소는 필수값입니다.")
    private String email;
    @NotBlank(message = "패스워드는 필수값입니다.")
    private String password;
    @NotBlank(message = "이름은 필수값입니다.")
    private String name;
    private String role;
}

member 테이블에 대한 쿼리를 정의하고 있는 MemberMapper.xml 에서 다른 쿼리들은 수정할 필요가 없는데, insert, update 쿼리는 수정이 필요하겠어.

MemberMapper.xml
...
<mapper namespace="com.woohahaapps.study.diary.mapper.MemberMapper">
    <insert id="CreateMember" parameterType="Member">
        insert into member (email, password, name, role) values (#{email}, #{password}, #{name}, #{role});
    </insert>
...
    <update id="UpdateMember" parameterType="Member">
        update diary
        set
            password = #{password}
          , name = #{name}
          , role = #{role}
        where
            email = #{email};
    </update>
...
</mapper>

member 테이블의 role 컬럼에 대해서 Default 값을 설정하지 않았으니 회원가입 서비스 클래스에서 회원가입을 할 때 기본값을 설정해줘야겠네.

Spring Boot: study.diary 객체의 사용 포스트에서 객체를 사용하는 방법으로 수정해봤는데, MemberService 에도 객체 사용 방법으로 수정한 후에 기본역할 ROLE_USER 를 설정하는 코드를 작성한거야.

MemberService.java
...
    public void signUp(String email, String password, String name) throws MemberException {
        if (email.isBlank() || password.isBlank() || name.isBlank()) {
            throw new MemberException("필수입력값이 없습니다: " + (email.isBlank() ? "email " : "") + (password.isBlank() ? "password " : "") + (name.isBlank() ? "name " : ""));
        }
        try {
            //memberMapper.CreateMember(email, passwordEncoder.encode(password), name);
            member.setPassword(passwordEncoder.encode(member.getPassword()));
            // 기본역할 설정
            member.setRole("ROLE_USER");
            memberMapper.CreateMember(member);
        } catch (Exception e) {
            System.out.println(e.getClass().getName());// org.springframework.dao.DuplicateKeyException
            if (e.getClass().getName().toLowerCase().contains("duplicatekey")){
                throw new MemberException("이메일주소가 이미 등록되어 있습니다: " + email);
            }
        }
    }

이제 새로 가입되는 회원은 ROLE_USER 역할도 설정되지.


이전에 가입한 회원의 role 컬럼이 비어있는 것은 어떻게 처리할까? 비어있는 role 을 처리하기 위한 로직을 구현하는 것보다는 아래 쿼리를 이용해서 기본값을 넣어서 처리하기로 했어.

update member set role = 'ROLE_USER'
where 
role is null;

자, 이제 회원 목록을 가져와볼까?

MemberService 에 MemberMapper 인터페이스의 GetAllMembers() 를 사용하는 함수를 하나 만들어볼께.

MemberService.java
...
@Service
public class MemberService {
   ...
    public List<Member> getMembers() {
        return memberMapper.GetAllMembers();
    }
}

MemberApiController 라는 이름으로 Rest API 클래스를 하나 만들고 여기에서 MemberService 의 getMembers 를 호출하는 코드를 작성해볼께. Spring Boot: study.diary : RestController 에서 ResponseEntity 를 리턴하자. 포스트에서 ResponseEntity 를 리턴하는 방법에 대해서 알아봤으니 여기에서도 ResponseEntity 를 사용하는 방법으로 코딩했어.

package com.woohahaapps.study.diary.controller;

import com.woohahaapps.study.diary.domain.Member;
import com.woohahaapps.study.diary.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/member")
public class MemberApiController {

    private final MemberService memberService;

    @Autowired
    public MemberApiController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("")
    public ResponseEntity<List<Member>> getMembers() {
        List<Member> members = memberService.getMembers();
        return ResponseEntity.ok().body(members);
    }
}

이제 swagger UI 를 이용해서 getMembers API 가 동작하는 모습을 확인해보자.


role 컬럼값이 정상적으로 내려오고 있는것까지 확인했네.

이제 본격적으로 Spring Security 에서 역할을 다루는 방법에 대해서 알아볼께.

Spring Security 역할 관리

다른 포스트들을 봤다면 LoginService 에서 이메일 주소가 일치하는 사용자 정보를 가져오는 코드를 기억할거야.

참고1: spring boot: study.diary : Spring Security 폼 로그인
참고2: Spring Boot: study.diary 멤버 가입
참고3: Spring Boot: study.diary 로그인 실패 사유 표시하기

로그인에 성공한 경우 User 클래스형의 객체가 생성되는데, User 클래스에서 역할과 관련된 멤버는 getAuthorities() 함수야.

User.java
...
@Data
public class User implements UserDetails {

    private Member member;

    public User(Member member) {
        this.member = member;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }
...

지금은 null 을 리턴하고 있는데, 역할 값을 설정하고 설정된 역할값을 리턴하도록 수정해볼께.

User.java
...
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        Collection<GrantedAuthority> authorities = new ArrayList<>();

        for(String role : member.getRole().split(",")){
            if (!StringUtils.hasText(role)) continue;
            authorities.add(new SimpleGrantedAuthority(role));
        }
        return authorities;
    }

member 객체의 getRole() 함수로 얻은 값을 쉼표 구분문자로 나누어서 SimpleGrantedAuthority 클래스형 객체를 생성할 때 파라미터로 전달을 했어.

이렇게 사용자의 역할값을 설정하면 User 객체가 어떻게 표현되는지를 한번 살펴보고 갈께.

작성한 일기를 저장하는 DiaryService 의 CreateDiary 함수에서 작성자의 이메일 주소를 구하기 위해서 SecurityContextHolder.getContext().getAuthentication().getName(); 를 사용했던 코드를 기억할거야.

참고: Spring Boot: study.diary : 로그인정보 조회

    public void CreateDiary(String date, String content) {
        System.out.println("Service:date=" + date + ",content=" + content);
        String email = SecurityContextHolder.getContext().getAuthentication().getName();
        diaryMapper.CreateDiary(date, content, email);
    }

이 코드를 조금 수정해서 로그인한 사용자정보를 출력하도록 해볼께.

    public void CreateDiary(String date, String content) {
        System.out.println("Service:date=" + date + ",content=" + content);
        String email = SecurityContextHolder.getContext().getAuthentication().getName();
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        System.out.println(authentication);
        diaryMapper.CreateDiary(date, content, email);
    }

아래 로그가 위에 작성된 System.out.println 으로 출력된 객체의 내용이야.

UsernamePasswordAuthenticationToken [Principal=User(member=com.woohahaapps.study.diary.domain.Member@21b9767a), Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=127.0.0.1, SessionId=7FB068B4583B4FE4C37901A54A2E2AFE], Granted Authorities=[ROLE_USER]]

맨 마지막에 Granted Authorities 의 값으로 ROLE_USER 가 getAuthorities() 가 리턴해주고 있는 값이지.

그럼 이 값을 어떻게 활용할 수 있을까? SecurityConfig 의 filterChain 함수의 일부 코드를 수정해서 활용 방법을 알아볼께.

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .csrf((csrf) -> csrf
                        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())// swagger 에서는 PUT, POST, DELETE method 를 위해서 필수(필수아님)로 기록해야 한다.
                        .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
                )
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/login", "/process_login"
                                , "/signup"
                                //, "/swagger-ui.html"
                                //, "/swagger-ui/**"
                                //, "/v3/api-docs/**"
                        ).permitAll()
                        .requestMatchers("/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**").hasRole("USER")
                        .anyRequest().authenticated()
                )
                .formLogin(form -> form
                        .loginPage("/login")
                        .loginProcessingUrl("/process_login")
                        .usernameParameter("email")
                        .failureHandler(customAuthFailureHandler)
                        .permitAll()
                )
                .logout(logout -> logout
                        .logoutSuccessUrl("/login")
                        .invalidateHttpSession(true)
                )
                .httpBasic(Customizer.withDefaults())// swagger 에서는 필수로 기록해야 한다.
                .build();
    }

swagger 와 관련된 url 3개를 항상 허용 목록에서 주석처리하여 제거하고, hasRole(“USER”) 영역을 새로 추가해서 3개의 url 을 등록했어. 코드를 보면 이해되겠지만, /swagger-ui.html, /swagger-ui 로 시작되는 URL, /v3/api-docs/ 로 시작되는 URL 은 USER 역할이 필요한 상태가 되는거야.

프로그램을 실행시키고 로그인하지 않은 상태에서 http://localhost:8080/swagger-ui.html 주소로 이동하면 로그인 화면으로 redirect 되는 상황을 확인할 수 있어. 로그인하면 그때서야 비로소 swagger UI 화면을 이용할 수가 있지.

그런데, 역할명으로 ROLE_USER 가 아니라 USER 를 사용하고 있을까?

USER 대신에 ROLE_USER 로 수정해서 실행시켜보면 로그로 그 이유를 알려주지.


파라미터값에 ROLE_ 을 자동으로 붙여주기 때문에 ROLE_ 로 시작하지 말아야 한다는군. 그렇기 때문에 DB 에 ROLE_ 이 붙은 값을 저장해 주어야 하는거야.

이제 ADMIN 역할이 있어야만 볼 수 있는(예를 들어 회원 목록) 화면을 만든다면 해당 화면으로 진입하는 URL 에 대해서 hasRole(“ADMIN”) 이라고 설정해주면 해당 화면은 ADMIN 역할의 사용자만 이용할 수 있게 되는거야.

코드에서 현재 로그인한 사용자의 역할을 확인하려면 User 클래스에 아래와 같은 로직의 코드를 사용하면 돼.

Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_ADMIN"))) {
    // ROLE_ADMIN 역할이 있음
} else if (authentication.getAuthorities().contains(new SimpleGrantedAuthority("ROLE_USER"))) {
    // ROLE_USER 역할이 있음
}

contains 함수에서 목록의 비교에는 equals 함수가 사용되는데, SimpleGrantedAuthority 클래스에 정의되어 있는 equals 는 아래와 같아.

    public boolean equals(Object obj) {
        if (this == obj) {
            return true;
        } else if (obj instanceof SimpleGrantedAuthority) {
            SimpleGrantedAuthority sga = (SimpleGrantedAuthority)obj;
            return this.role.equals(sga.getAuthority());
        } else {
            return false;
        }
    }

추가로 타임리프에서는 sec:authorize=”hasRole(‘ADMIN’)” 와 같이 역할을 체크할 수 있어.

editdiary.html


...
        <th:block sec:authorize="hasRole('ADMIN')">
        <input type="button" class="btn btn-danger" value="삭제" th:onclick="deleteDiary()" />
        </th:block>
...

위 코드는 ADMIN 역할에게만 삭제 버튼이 보여지게 하는 방법의 코드야.

반응형