최원종의 개발 블로그

V11 - 2 역할 기반 접근 제어 본문

Spring boot 입문

V11 - 2 역할 기반 접근 제어

chl6698 2026. 5. 26. 15:23

로그인 시 권한 정보 함께 조회로 수정

SQL문

select u.*, r.role 
from user_tb u
left join user_role_tb r on u.id = r.user_id
WHERE u.username = 'admin' AND u.password = '1234';

UserService (로그인 및 회원가입 로직 수정)

 

/user/Repository.java

    // 사용자명과 비밀번호로 사용자 조회(로그인용) + Role 정보 한번에 조회
    @Query("""
         SELECT distinct u FROM User u 
         LEFT JOIN FETCH  u.roles r
         WHERE u.username = :username AND u.password = :password        
    """)
    Optional<User> findByUsernameAndPasswordWithRoles(@Param("username") String username,
                                             @Param("password")  String password);

 


회원가입 메서드 수정 

UserService 코드⬇️

추가 코드
 // 기본 권한 추가 (일반 사용자로 설정)
        user.addRole(Role.USER);
더보기
// http://192.168.4.101:8080/join-form (강사 서버 컴퓨터 주소)
    /**
     * 회원 가입 처리
     * @param joinDTO (사용자 회원가입 요청 정보)
     * @return User (저장된 사용자 정보)
     */
    @Transactional
    public User 회원가입(UserRequest.JoinDTO joinDTO) {
        log.info("회원가입 서비스 시작");

        // 회원가입시 사용자 이름 중복 체크
        userRepository.findByUsername(joinDTO.getUsername()).ifPresent(user -> {
            log.warn("회원가입 실패 - 중복된 사용자명 : {}", user.getUsername());
            throw new Exception400("이미 존재하는 사용자 이름입니다");
        });


        // 프로필 이미지 저장 기능 구현 (선택 사항 임)
        String profileImageFilename = null;
        if(joinDTO.getProfileImage() != null && joinDTO.getProfileImage().isEmpty() == false) {
            try {
                // 이미지 파일이 맞는지 검증
                if(FileUtil.isImageFile(joinDTO.getProfileImage()) == false) {
                    throw new Exception400("이미지 파일만 업로드 가능합니다");
                }
                profileImageFilename = FileUtil.saveFile(joinDTO.getProfileImage(), FileUtil.IMAGES_DIR);
            } catch (Exception e) {
                // 디스크 공간 없거나, 권한 없음
                throw new Exception500("프로필 이미지 저장 실패");
            }
        }
        // 코드 수정
        User user = joinDTO.toEntity(profileImageFilename);

        // 기본 권한 추가 (일반 사용자로 설정)
        user.addRole(Role.USER);

        return userRepository.save(user);
    }

WebMvcConfig 코드(  /admin/**도 로그인 필수로 추가)⬇️​

더보기
package com.tenco.blog._core.config;

import com.tenco.blog._core.interceptor.AdminInterceptor;
import com.tenco.blog._core.interceptor.LoginInterceptor;
import com.tenco.blog._core.interceptor.SessionInterceptor;
import com.tenco.blog._core.util.FileUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.nio.file.Paths;

// 자바 코드로 스프링 부트 설정 파일을 다둘 수 있다.

// @Component
@Configuration // IoC 대상 - 하나 이상의 IoC 처리를 하고 싶을 때 사용 한다.
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired // DI 처리
    private LoginInterceptor loginInterceptor;
    @Autowired // DI 처리
    private SessionInterceptor sessionInterceptor;
    @Autowired
    private AdminInterceptor adminInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {

        // 화면에 SessionUser 정보를 내려줄 사용 됨. 
        registry.addInterceptor(sessionInterceptor)
                .addPathPatterns("/**"); // 모든 URL 요청서 동작 함


        // 인증 처리 인터셉터 동작 함
        registry.addInterceptor(loginInterceptor)
                .addPathPatterns("/board/**", "/user/**", "/reply/**", "/admin/**")
                .excludePathPatterns(
                    // 로그인 관련(인증이 필요 없는 페이지)
                    "/login-form",  // 로그인 화면 요청 시
                    "/join-form",   // 회원 가입 화면 요청 시
                    "/logout", // 로그아웃

                    // 게시글 조회 관련 (인증 없이도 볼 수 있는 페이지)
                    "/board/list",  // 게시글 목록 화면 요청 시
                    "/"          ,  // 메인 페이지
                    "/index"          ,  // 메인 페이지
                    "/board/{id:\\d+}", // 게시글 상세보기( 숫자 ID만 허용)

                    // 정적 리소스 (CSS, JS, 이미지 등)
                    "/css/**",          // CSS 파일 제외
                    "/js/**",           // JS 파일 제외
                    "/images/**",      //  이미지 파일 제외
                    "/favicon.ico",    // 파비콘 제외

                    // H2 데이터베시스 콘솔( 개발 환경용)
                    "/h2-console/**"    // H2 콘솔 접근
                );

        // 관리자 페이지 요청이 들어 왔을 때 1단계 로그인 여부 확인, 2단계 Role 확인해서
        // ADMIN 일 경우만 관리자 페이지로 이동 가능하게 처리
        registry.addInterceptor(adminInterceptor).addPathPatterns("/admin/**");
    }

    // 정적 리소스 핸들러 설정
    // 외부 사용자가 내 서버 컴퓨터에 특정 경로를 바로 확인을 할 수 있게 한다면
    // 보안상 취약 할 수 있습니다. ( 사용자에게는 가짜 경로를 보여주고 내부에서는
    // 실정 경로를 찾을 수 있도록 처리 하는 기법(보안상)

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
       String externalPath = Paths.get(FileUtil.IMAGES_DIR).toString();
       registry.addResourceHandler("/images/**")
                // file: 추가 하기
               .addResourceLocations("file:" + externalPath);
    }
}

AdminInterceptor ( 관리자 권한 체크 생성)

package com.tenco.blog._core.interceptor;

import com.tenco.blog._core.errors.Exception401;
import com.tenco.blog._core.errors.Exception403;
import com.tenco.blog._core.util.Define;
import com.tenco.blog.user.User;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class AdminInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 세션에서 로그인 사용자 정보 조회
        // 단, AdminInterceptor 동작하기 전에 LoginInterceptor 가 먼저 선언이 되어 동작하기 된다.
        // 즉 로그인은 보장 되어 있음 ! (순서 중요)
        HttpSession session = request.getSession();
        User sessionUser = (User) session.getAttribute(Define.SESSION_USER);
        if(sessionUser == null) {
            throw new Exception401("로그인이 필요합니다");
        }
        if(!sessionUser.isAdmin()) {
            throw new Exception403("관리자 권한이 필요합니다");
        }
        return true;
    }
}

AdminController ( 관리자 전용 컨트롤러 생성)

package com.tenco.blog.admin;

import com.tenco.blog._core.errors.Exception403;
import com.tenco.blog._core.util.Define;
import com.tenco.blog.user.User;
import jakarta.servlet.http.HttpSession;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class AdminController {

    @GetMapping("/admin/dashboard")
    public String dashboardPage(HttpSession session, Model model) {

        User sessionUser = (User) session.getAttribute(Define.SESSION_USER);
        model.addAttribute("admin", sessionUser);
        return "admin/dashboard";
    }

}

헤더 메뉴 및 관리자 대시보드 뷰

src/main/resources/templates/layout/header.mustache 코드
더보기
<!DOCTYPE html>
<html lang="en">

<head>
    <title>Blog</title>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"/>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head>

<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-sm bg-dark navbar-dark">
    <div class="container-fluid">
        <a class="navbar-brand" href="/">Tencoding</a>
        <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#collapsibleNavbar">
            <span class="navbar-toggler-icon"></span>
        </button>
        <div class="collapse navbar-collapse d-flex justify-content-between" id="collapsibleNavbar">
            <ul class="navbar-nav">
                {{#sessionUser}}
                    <li class="nav-item">
                        <a class="nav-link" href="/board/save-form">글쓰기</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="/user/detail">마이페이지</a>
                    </li>
                    {{#admin}}
                    <li class="nav-item">
                        <a class="nav-link" href="/admin/dashboard">관리자</a>
                    </li>
                    {{/admin}}

                    <li class="nav-item">
                        <a class="nav-link" href="/logout">로그아웃</a>
                    </li>
                {{/sessionUser}}
                {{^sessionUser}}
                    <li class="nav-item">
                        <a class="nav-link" href="/join-form">회원가입</a>
                    </li>
                    <li class="nav-item">
                        <a class="nav-link" href="/login-form">로그인</a>
                    </li>
                {{/sessionUser}}
            </ul>

            <ul class="navbar-nav ml-auto">
                {{#sessionUser}}
                <li class="nav-item d-flex">
                    <span class="badge bg-info">{{roleDisplay}}</span>
                    <span class="badge bg-info">{{username}} 님</span>
                </li>
                {{/sessionUser}}
            </ul>

        </div>
    </div>
</nav>

관리자 대시보드 뷰

src/main/resources/templates/admin/dashboard.mustache 코드
{{> layout/header}}
<div class="container p-5 flex-grow-1">
    <div class="card">
        <div class="card-header">관리자페이지 - 대쉬보드</div>
        <div class="card-body">
            <h3>여기는 ROLE ADMIN 접근 가능한 페이지 입니다</h3>
            <p>{{admin.username}} 님 반갑습니다</p>
        </div>
    </div>
</div>
{{> layout/footer}}