Spring Security를 사용한 통합 로그인 및 권한 관리

  1. 프로젝트 개요

초기에는 모든 기능 모듈이 단일 애플리케이션에 포함되었으나, 프로젝트 규모가 커지면서 일부 기능을 독립적인 마이크로서비스로 분리하기로 결정했습니다. 이 과정에서 로그인/로그아웃, 권한 제어와 같은 공통 기능을 각 서비스에 복사하는 대신, 이를 별도의 공통 서비스로 분리하는 것이 가장 효과적입니다. 이에 따라 아래와 같은 아키텍처를 구축하게 되었습니다:

  1. 통합 인증 서버

이 구현에서는 Spring Security와 Spring Security OAuth2를 사용했습니다. 주로 사용되는 인가 코드 방식과 패스워드 방식을 모두 지원합니다. 필요한 의존성을 추가하는 것으로 시작합니다. 핵심 의존성은 spring-cloud-starter-oauth2입니다.

<dependency>
  <groupId>org.springframework.cloud</groupId>
  <artifactId>spring-cloud-starter-oauth2</artifactId>
  <version>2.2.5.RELEASE</version>
</dependency>

사용 중인 Spring Boot 버전은 2.6.3이며, 전체 pom.xml은 다음과 같습니다:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.tgf</groupId>
        <artifactId>tgf-service-parent</artifactId>
        <version>1.3.0</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.soa.supervision.uaa</groupId>
    <artifactId>soas-uaa</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>soas-uaa</name>
    <properties>
        <java.version>1.8</java.version>
        <spring-cloud.version>2021.0.0</spring-cloud.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-oauth2</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>nimbus-jose-jwt</artifactId>
            <version>9.19</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-generator</artifactId>
            <version>3.5.1</version>
        </dependency>
        <dependency>
            <groupId>org.mybatis.scripting</groupId>
            <artifactId>mybatis-freemarker</artifactId>
            <version>1.2.3</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

인증 서버 구성

인증 서버는 토큰 생성 방식과 등록된 클라이언트 정보를 설정합니다.

package com.soa.supervision.uaa.config;

import com.soa.supervision.uaa.constant.AuthConstants;
import com.soa.supervision.uaa.domain.SecureUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.config.annotation.configurers.ClientDetailsServiceConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configuration.AuthorizationServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableAuthorizationServer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerEndpointsConfigurer;
import org.springframework.security.oauth2.config.annotation.web.configurers.AuthorizationServerSecurityConfigurer;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.client.JdbcClientDetailsService;
import org.springframework.security.oauth2.provider.endpoint.TokenKeyEndpoint;
import org.springframework.security.oauth2.provider.token.TokenEnhancer;
import org.springframework.security.oauth2.provider.token.TokenEnhancerChain;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.KeyStoreKeyFactory;

import javax.annotation.Resource;
import javax.sql.DataSource;
import java.security.KeyPair;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * 인증 서버 설정 
 * 1. 클라이언트 설정 
 * 2. Access_Token 생성 설정 
 * 
 * @Author ChengJianSheng 
 * @Date 2022/2/14
 */
@Configuration
@EnableAuthorizationServer
public class AuthServerConfig extends AuthorizationServerConfigurerAdapter {
    @Resource
    private DataSource dataSource;
    @Autowired
    private AuthenticationManager authenticationManager;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        clients.withClientDetails(new JdbcClientDetailsService(dataSource));
    }

    @Override
    public void configure(AuthorizationServerSecurityConfigurer security) throws Exception {
        security.allowFormAuthenticationForClients();
        // security.tokenKeyAccess("permitAll()"); 
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        List<TokenEnhancer> enhancerList = new ArrayList<>();
        enhancerList.add(jwtTokenEnhancer());
        enhancerList.add(jwtAccessTokenConverter());
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(enhancerList);
        endpoints.accessTokenConverter(jwtAccessTokenConverter()).tokenEnhancer(tokenEnhancerChain).authenticationManager(authenticationManager);
    }

    /**
     * 토큰 강화
     */
    public TokenEnhancer jwtTokenEnhancer() {
        return new TokenEnhancer() {
            @Override
            public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
                SecureUser secureUser = (SecureUser) authentication.getPrincipal();
                Map<String, Object> additionalInfo = new HashMap<>();
                additionalInfo.put(AuthConstants.JWT_USER_ID_KEY, secureUser.getUserId());
                additionalInfo.put(AuthConstants.JWT_USER_NAME_KEY, secureUser.getUsername());
                additionalInfo.put(AuthConstants.JWT_DEPT_ID_KEY, secureUser.getDeptId());
                ((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
                return accessToken;
            }
        };
    }

    /**
     * RSA 알고리즘으로 JWT 서명
     */
    public JwtAccessTokenConverter jwtAccessTokenConverter() {
        JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        converter.setKeyPair(keyPair());
        return converter;
    }

    /**
     * 키 쌍 생성
     */
    @Bean
    public KeyPair keyPair() {
        KeyStoreKeyFactory factory = new KeyStoreKeyFactory(new ClassPathResource("jwt.jks"), "123456".toCharArray());
        return factory.getKeyPair("jwt", "123456".toCharArray());
    }

    @Bean
    public TokenKeyEndpoint tokenKeyEndpoint() {
        return new TokenKeyEndpoint(jwtAccessTokenConverter());
    }
}

설명:

  • 클라이언트 정보는 데이터베이스에서 로드됩니다
  • 패스워드 인가 모드에서는 반드시 AuthenticationManager를 설정해야 합니다
  • JWT는 경량형 토큰으로 서버 저장 부담을 줄이지만, 로그아웃 기능을 위해 Redis에 저장해야 합니다
  • JWT 암호화는 리소스 서버의 토큰 검증에 필수적입니다
  • 토큰에 사용자 ID, 부서 ID 등의 추가 정보를 삽입합니다

클라이언트 테이블 구조:

DROP TABLE IF EXISTS `oauth_client_details`;
CREATE TABLE `oauth_client_details`
(
    `client_id`               varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '클라이언트 ID',
    `resource_ids`            varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `client_secret`           varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '클라이언트 비밀',
    `scope`                   varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `authorized_grant_types`  varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '인가 유형',
    `web_server_redirect_uri` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `authorities`             varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `access_token_validity`   int(11) NULL DEFAULT NULL COMMENT 'access_token 유효 시간',
    `refresh_token_validity`  int(11) NULL DEFAULT NULL COMMENT 'refresh_token 유효 시간',
    `additional_information`  varchar(4096) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
    `autoapprove`             varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '자동 승인 여부',
    PRIMARY KEY (`client_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = DYNAMIC;

INSERT INTO `oauth_client_details` VALUES ('hello', 'order-resource', '$2a$10$1Vun/h63tI4C48BqLsy2Zel5q5M2VW6w8KThoMfxww49wf9uv/dKy', 'all', 'authorization_code,password,refresh_token', 'http://www.baidu.com', NULL, 7200, 7260, NULL, 'true');
INSERT INTO `oauth_client_details` VALUES ('sso-client-1', NULL, '$2a$10$CxEwmODmsp/HOB7LloeBJeqUjotmNzjpk2WmjxtPxAeOYifQWLfhW', 'all', 'authorization_code', 'http://localhost:9001/sso-client-1/login/oauth2/code/custom', NULL, 180, 240, NULL,'true');

이 예제에서는 Java keytool로 생성한 RSA 비대칭 암호화 키를 사용합니다. 인증 서버는 개인 키로 토큰을 암호화하고 공개 키를 공개합니다.

package com.soa.supervision.uaa.controller;

import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;
import java.util.Map;

/**
 * @Author ChengJianSheng 
 * @Date 2022/2/15
 */
@RestController
public class PublicKeyController {
    @Autowired
    private KeyPair keyPair;

    @GetMapping("/rsa/publicKey")
    public Map<String, Object> getKey() {
        RSAPublicKey publicKey = (RSAPublicKey) this.keyPair.getPublic();
        RSAKey key = new RSAKey.Builder(publicKey).build();
        return new JWKSet(key).toJSONObject();
    }
}

WebSecurity 구성

WebSecurity는 사용자 설정과 인증 필요 경로를 정의합니다.

package com.soa.supervision.uaa.config;

import com.soa.supervision.uaa.service.impl.UserDetailsServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
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.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;

/**
 * @Author ChengJianSheng
 * @Date 2022/2/14
 */
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private UserDetailsServiceImpl userDetailsService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().requestMatchers(EndpointRequest.toAnyEndpoint()).permitAll().antMatchers("/rsa/publicKey", "/menu/tree").permitAll().anyRequest().authenticated().and().formLogin().permitAll().and().csrf().disable();
    }

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

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

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

UserDetailsService 구현

package com.soa.supervision.uaa.service.impl;

import com.soa.supervision.uaa.domain.AuthUserDTO;
import com.soa.supervision.uaa.domain.SecureUser;
import com.soa.supervision.uaa.service.SysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.LockedException;
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.stereotype.Service;

import java.util.Set;
import java.util.stream.Collectors;

/**
 * @Author ChengJianSheng
 * @Date 2022/2/14
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
    @Autowired
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        AuthUserDTO authUserDTO = sysUserService.getAuthUserByUsername(username);
        if (null == authUserDTO) {
            throw new UsernameNotFoundException("사용자 없음");
        }
        if (!authUserDTO.isEnabled()) {
            throw new LockedException("계정 잠김");
        }
        Set authorities = authUserDTO.getRoles().stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
        return new SecureUser(authUserDTO.getUserId(), authUserDTO.getDeptId(), authUserDTO.getUsername(), authUserDTO.getPassword(), authUserDTO.isEnabled(), authorities);
    }
}

SysUserService

package com.soa.supervision.uaa.service;

import com.soa.supervision.uaa.domain.AuthUserDTO;
import com.soa.supervision.uaa.entity.SysUser;
import com.baomidou.mybatisplus.extension.service.IService;

/**
 * 사용자 서비스 인터페이스 
 * 
 * @author ChengJianSheng 
 * @since 2022-02-14
 */
public interface SysUserService extends IService<SysUser> {
    AuthUserDTO getAuthUserByUsername(String username);
}

AuthUserDTO

package com.soa.supervision.uaa.domain;

import lombok.Data;

import java.io.Serializable;
import java.util.List;

/**
 * @Author ChengJianSheng 
 * @Date 2022/2/15
 */
@Data
public class AuthUserDTO implements Serializable {
    private Integer userId;
    private String username;
    private String password;
    private Integer deptId;
    private boolean enabled;
    private List<String> roles;
}

SysUserServiceImpl

package com.soa.supervision.uaa.service.impl;

import com.soa.supervision.uaa.domain.AuthUserDTO;
import com.soa.supervision.uaa.entity.SysUser;
import com.soa.supervision.uaa.mapper.SysUserMapper;
import com.soa.supervision.uaa.service.SysUserService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

/**
 * 사용자 서비스 구현
 * 
 * @author ChengJianSheng 
 * @since 2022-02-14
 */
@Service
public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> implements SysUserService {
    @Autowired
    private SysUserMapper sysUserMapper;

    @Override
    public AuthUserDTO getAuthUserByUsername(String username) {
        return sysUserMapper.selectAuthUserByUsername(username);
    }
}

SysUserMapper

package com.soa.supervision.uaa.mapper;

import com.soa.supervision.uaa.domain.AuthUserDTO;
import com.soa.supervision.uaa.entity.SysUser;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * 사용자 매퍼 인터페이스 
 * 
 * @author ChengJianSheng 
 * @since 2022-02-14
 */
public interface SysUserMapper extends BaseMapper<SysUser> {
    AuthUserDTO selectAuthUserByUsername(String username);
}

SysUserMapper.xml

<!-- 사용자 조회 -->
<select id="selectAuthUserByUsername">
    SELECT t1.id,
        t1.username,
        t1.`password`,
        t1.dept_id,
        t1.enabled,
        t3.`code` AS role_code
    FROM sys_user t1
    LEFT JOIN sys_user_role t2 ON t1.id = t2.user_id
    LEFT JOIN sys_role t3 ON t2.role_id = t3.id
    WHERE t1.username = #{username}
</select>

SecureUser

package com.soa.supervision.uaa.domain;

import lombok.AllArgsConstructor;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;

import java.util.Collection;
import java.util.Set;

/**
 * @Author ChengJianSheng
 * @Date 2022/2/14
 */
@AllArgsConstructor
public class SecureUser implements UserDetails {
    /**
     * 확장 필드
     */
    private Integer userId;
    private Integer deptId;
    private String username;
    private String password;
    private boolean enabled;
    private Set<String> authorities;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return authorities.stream().map(SimpleGrantedAuthority::new).collect(java.util.stream.Collectors.toSet());
    }

    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public String getUsername() {
        return username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    public Integer getUserId() {
        return userId;
    }

    public Integer getDeptId() {
        return deptId;
    }
}

로그인

기본 로그인 URL은 /login이며, 예제에서는 패스워드 인가 모드를 사용하여 /oauth/token 엔드포인트를 커스터마이징했습니다.

package com.soa.supervision.uaa.controller;

import com.tgf.common.domain.RespResult;
import com.tgf.common.util.RespUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.provider.endpoint.TokenEndpoint;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.*;

import java.security.Principal;
import java.util.HashMap;
import java.util.Map;

/**
 * @Author ChengJianSheng
 * @Date 2022/2/18
 */
@RestController
@RequestMapping("/oauth")
public class AuthController {
    @Autowired
    private TokenEndpoint tokenEndpoint;

    /**
     * 패스워드 인가 로그인
     *
     * @param principal
     * @param parameters
     * @return
     * @throws HttpRequestMethodNotSupportedException
     */
    @PostMapping("/token")
    public RespResult postAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
        OAuth2AccessToken oAuth2AccessToken = tokenEndpoint.postAccessToken(principal, parameters).getBody();
        Map<String, Object> map = new HashMap<>(); // 캐시
        return RespUtils.success();
    }

    /**
     * 로그아웃
     * @return
     */
    @PostMapping("/logout")
    public RespResult logout() {
//        JSONObject payload = JwtUtils.getJwtPayload();
//        String jti = payload.getStr(SecurityConstants.JWT_JTI);         // JWT 고유 식별자
//        Long expireTime = payload.getLong(SecurityConstants.JWT_EXP);   // JWT 만료 시간(초 단위)
//        if (expireTime != null) {
//            long currentTime = System.currentTimeMillis() / 1000;       // 현재 시간(초 단위)
//            if (expireTime > currentTime) {  // 토큰 미만료 상태, 남은 시간만큼 블랙리스트로 저장
//                redisTemplate.opsForValue().set(SecurityConstants.TOKEN_BLACKLIST_PREFIX + jti, null, (expireTime - currentTime), TimeUnit.SECONDS);
//            }
//        } else {  // 영원히 만료되지 않는 토큰은 영구적으로 블랙리스트에 추가
//            redisTemplate.opsForValue().set(SecurityConstants.TOKEN_BLACKLIST_PREFIX + jti, null);
//        }
//        return Result.success("로그아웃 성공");
        return RespUtils.success();
    }
}

보완:

인가 코드 방식으로 access_token을 얻는 방법

메뉴

로그인 후, 프론트엔드는 메뉴를 조회하여 표시합니다. 메뉴 관련 인터페이스는 다음과 같습니다.

SysMenuController

package com.soa.supervision.uaa.controller;

import com.soa.supervision.uaa.domain.MenuVO;
import com.soa.supervision.uaa.service.SysMenuService;
import com.tgf.common.domain.RespResult;
import com.tgf.common.util.RespUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;
import java.util.List;

/**
 * 메뉴 컨트롤러 
 * @author ChengJianSheng 
 * @since 2022-02-21
 */
@RestController
@RequestMapping("/menu")
public class SysMenuController {
    @Autowired
    private SysMenuService sysMenuService;

    @GetMapping("/tree")
    public RespResult tree(@RequestHeader("userId") Integer userId, String systemCode) {
        if (StringUtils.isBlank(systemCode)) {
            systemCode = "ADMIN";
        }
        List<MenuVO> voList = sysMenuService.getMenuByUserId(systemCode, userId);
        return RespUtils.success(voList);
    }
}

SysMenuService

package com.soa.supervision.uaa.service;

import com.soa.supervision.uaa.domain.MenuVO;
import com.soa.supervision.uaa.entity.SysMenu;
import com.baomidou.mybatisplus.extension.service.IService;

import java.util.List;

/**
 * 메뉴 서비스 인터페이스 
 * @author ChengJianSheng 
 * @since 2022-02-21
 */
public interface SysMenuService extends IService<SysMenu> {
    List<MenuVO> getMenuByUserId(String systemCode, Integer userId);
} 

SysMenuServiceImpl

package com.soa.supervision.uaa.service.impl;

import com.soa.supervision.uaa.domain.MenuVO;
import com.soa.supervision.uaa.entity.SysMenu;
import com.soa.supervision.uaa.mapper.SysMenuMapper;
import com.soa.supervision.uaa.service.SysMenuService;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

/**
 * 메뉴 서비스 구현
 *
 * @author ChengJianSheng
 * @since 2022-02-21
 */
@Service
public class SysMenuServiceImpl extends ServiceImpl<SysMenuMapper, SysMenu> implements SysMenuService {
    @Autowired
    private SysMenuMapper sysMenuMapper;

    /**
     * 메뉴 트리 생성
     * @param systemCode
     * @param roleIds
     * @return
     */
    @Override
    public List<MenuVO> getMenuByUserId(String systemCode, Integer userId) {
        List<MenuVO> voList = new ArrayList<>();
        List<SysMenu> sysMenuList = sysMenuMapper.selectMenuByUserId(systemCode, userId);
        if (null == sysMenuList || sysMenuList.size() == 0) {
            return voList;
        }
        List<MenuVO> menuVOList = sysMenuList.stream().map(e -> {
            MenuVO vo = new MenuVO();
            BeanUtils.copyProperties(e, vo);
            vo.setChildren(new ArrayList<>());
            return vo;
        }).distinct().collect(Collectors.toList());
        for (int i = 0; i < menuVOList.size(); i++) {
            for (int j = 0; j < menuVOList.size(); j++) {
                if (menuVOList.get(i).getId().equals(menuVOList.get(j).getId())) {
                    continue;
                }
                if (menuVOList.get(i).getId().equals(menuVOList.get(j).getParentId())) {
                    menuVOList.get(i).getChildren().add(menuVOList.get(j));
                }
            }
        }
        return menuVOList.stream().filter(e -> 0 == e.getParentId()).collect(Collectors.toList());
    }
}

MenuVO

package com.soa.supervision.uaa.domain;

import lombok.Data;

import java.io.Serializable;
import java.util.List;

/**
 * @Author ChengJianSheng 
 * @Date 2022/2/21
 */
@Data
public class MenuVO implements Serializable {
    private Integer id;
    /**
     * 메뉴 이름
     */
    private String name;
    /**
     * 상위 메뉴 ID
     */
    private Integer parentId;
    /**
     * 라우트 경로
     */
    private String routePath;
    /**
     * 컴포넌트
     */
    private String component;
    /**
     * 아이콘
     */
    private String icon;
    /**
     * 정렬 순서
     */
    private Integer sort;
    /**
     * 하위 메뉴
     */
    private List<MenuVO> children;
} 

SysMenuMapper

package com.soa.supervision.uaa.mapper;

import com.soa.supervision.uaa.entity.SysMenu;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

/**
 * 메뉴 매퍼 인터페이스 
 * @author ChengJianSheng 
 * @since 2022-02-21
 */
public interface SysMenuMapper extends BaseMapper<SysMenu> {
    List<SysMenu> selectMenuByUserId(@Param("systemCode") String systemCode, @Param("userId") Integer userId);
}

SysMenuMapper.xml

<!-- 사용자 메뉴 조회 -->
<select id="selectMenuByUserId">
    SELECT
        t1.*
    FROM sys_menu t1
        INNER JOIN sys_role_menu t2 ON t1.id = t2.menu_id
        INNER JOIN sys_user_role t3 ON t2.role_id = t3.role_id
    WHERE t1.type = 1
        AND t1.hidden = 0
        AND t1.system_code = #{systemCode} AND t3.user_id = #{userId}
    ORDER BY t1.sort ASC
</select>

application.yml

server:
  port: 8094
  servlet:
    context-path: /soas-uaa
spring:
  application:
    name: soas-uaa
  datasource:
    url: jdbc:mysql://192.168.28.22:3306/demo?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8&useSSL=false
    driver-class-name: com.mysql.cj.jdbc.Driver
    username: root
    password: 1234567
  redis:
    host: 192.168.28.01
    port: 6379
    password: 123456
logging:
  level:
    org:
      springframework:
        security: debug
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl 
  1. 게이트웨이

게이트웨이는 OAuth2의 리소스 서버 역할을 수행합니다. 게이트웨이는 모든 비즈니스 마이크로서비스를 프록시하며, 리소스에 접근하려면 인증과 권한 부여가 필요합니다. 게이트웨이에서 권한 검증을 하는 것이 적절합니다. 먼저 필요한 의존성을 확인합니다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.tgf</groupId>
        <artifactId>tgf-service-parent</artifactId>
        <version>1.3.1-SNAPSHOT</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.soa.supervision.gateway</groupId>
    <artifactId>soas-gateway</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>soas-gateway</name>
    <properties>
        <java.version>1.8</java.version>
        <spring-security.version>5.6.1</spring-security.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-gateway</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-config</artifactId>
            <version>${spring-security.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-resource-server</artifactId>
            <version>${spring-security.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.security</groupId>
            <artifactId>spring-security-oauth2-jose</artifactId>
            <version>${spring-security.version}</version>
        </dependency>
        <!-- spring-security-oauth2-jose 의존성에 포함된 nimbus-jose-jwt의 버전이 최신이 아님. 더 최신 버전을 사용하려면 재선언 가능 -->
        <dependency>
            <groupId>com.nimbusds</groupId>
            <artifactId>nimbus-jose-jwt</artifactId>
            <version>9.15.2</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-collections4</artifactId>
        </dependency>
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>5.7.21</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>
    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.cloud</groupId>
                <artifactId>spring-cloud-dependencies</artifactId>
                <version>${spring-cloud.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

application.yml

server:
  port: 8090
spring:
  cloud:
    gateway:
      routes:
        - id: soas-enterprise
          uri: http://127.0.0.1:8093
          predicates:
            - Path=/soas-enterprise/**
        - id: soas-portal
          uri: http://127.0.0.1:8092
          predicates:
            - Path=/soas-portal/**
        - id: soas-finance
          uri: http://127.0.0.1:8095
          predicates:
            - Path=/soas-finance/**
      discovery:
        locator:
          enabled: false
  redis:
    host: 192.168.28.01
    port: 6379
    password: 123456
    database: 9
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: http://localhost:8094/soas-uaa/rsa/publicKey
secure:
  ignore:
    urls:
      - /soas-portal/auth/**

직접 허용할 URL

package com.soa.supervision.gateway.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * @Author ChengJianSheng 
 * @Date 2021/12/15
 */
@Data
@Component
@ConfigurationProperties(prefix = "secure.ignore")
public class IgnoreUrlProperties {
    private String[] urls;
} 

권한 검증

실제 권한 판단은 다음 코드에서 수행되며, 게이트웨이 필터 이전에 실행됩니다.

package com.soa.supervision.gateway.config;

import com.alibaba.fastjson.JSON;
import com.soa.supervision.gateway.constant.AuthConstants;
import com.soa.supervision.gateway.constant.RedisConstants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.security.authorization.AuthorizationDecision;
import org.springframework.security.authorization.ReactiveAuthorizationManager;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.web.server.authorization.AuthorizationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.PathMatcher;
import reactor.core.publisher.Mono;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * @Author ChengJianSheng 
 * @Date 2022/2/16
 */
@Slf4j
@Component
public class AuthManager implements ReactiveAuthorizationManager {
    private final PathMatcher pathMatcher = new AntPathMatcher();
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, AuthorizationContext context) {
        ServerHttpRequest request = context.getExchange().getRequest();
        String path = request.getURI().getPath();
        // 토큰이 존재하고 유효해야 함 
        String token = request.getHeaders().getFirst(AuthConstants.JWT_TOKEN_HEADER);
        if (StringUtils.isBlank(token) || !token.startsWith(AuthConstants.JWT_TOKEN_PREFIX)) {
            return Mono.just(new AuthorizationDecision(false));
        }
        String realToken = token.trim().substring(7);
        Long ttl = stringRedisTemplate.getExpire(RedisConstants.ONLINE_TOKEN_PREFIX_KV + realToken);
        if (ttl <= 0) {
            return Mono.just(new AuthorizationDecision(false));
        }
        // 접근 가능한 역할 조회 
        List<String> requiredRoles = new ArrayList<>();
        // 접근 권한이 있는 역할 
        Map<Object, Object> urlRoleMap = stringRedisTemplate.opsForHash().entries(RedisConstants.URL_ROLE_MAP_HK);
        for (Map.Entry<Object, Object> entry : urlRoleMap.entrySet()) {
            String permissionUrl = (String) entry.getKey();
            List<String> roles = JSON.parseArray((String) entry.getValue(), String.class);
            if (pathMatcher.match(permissionUrl, path)) {
                requiredRoles.addAll(roles);
            }
        }
        // 권한 규칙이 없으면 자동 허용 
        if (CollectionUtils.isEmpty(requiredRoles)) {
            return Mono.just(new AuthorizationDecision(true));
        }
        // 사용자가 해당 역할을 가졌는지 확인 
        return authentication
                .filter(Authentication::isAuthenticated)
                .flatMapIterable(Authentication::getAuthorities)
                .map(GrantedAuthority::getAuthority)
                .any(requiredRoles::contains)
                .map(AuthorizationDecision::new)
                .defaultIfEmpty(new AuthorizationDecision(false));
    }
}

Redis에 저장된 메뉴 권한은 다음과 같이 저장됩니다: url -> [역할코드, 역할코드, ...]

쿼리 SQL

SELECT 
    t1.url, t3.`code` AS role_code
FROM sys_menu t1
     LEFT JOIN sys_role_menu t2 ON t1.id = t2.menu_id
     LEFT JOIN sys_role t3 ON t2.role_id = t3.id
WHERE t1.url is NOT NULL; 

Redis에 저장

 HSET "/soas-order/order/pageList" "[\"admin\",\"org\"]" HSET "/soas-order/order/save" "[\"admin\",\"enterprise\"]" 

리소스 액세스 설정

ResourceServerConfig

package com.soa.supervision.gateway.config;

import cn.hutool.core.codec.Base64;
import cn.hutool.core.io.IoUtil;
import com.soa.supervision.gateway.util.ResponseUtils;
import lombok.SneakyThrows;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity;
import org.springframework.security.config.web.server.ServerHttpSecurity;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverterAdapter;
import org.springframework.security.web.server.SecurityWebFilterChain;
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.io.InputStream;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.X509EncodedKeySpec;

/**
 * @Author ChengJianSheng 
 * @Date 2022/02/15
 */
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
    @Autowired
    private IgnoreUrlProperties ignoreUrlProperties;
    @Autowired
    private AuthManager authManager;

    @Bean
    public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
        // JWT 디코딩 설정 
        http.oauth2ResourceServer().jwt().jwtAuthenticationConverter(jwtAuthenticationConverter());//.publicKey(rsaPublicKey()); 
        http.authorizeExchange().pathMatchers(ignoreUrlProperties.getUrls()).permitAll()
                .anyExchange().access(authManager)
                .and()
                .exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler())
                .authenticationEntryPoint(authenticationEntryPoint())
                .and()
                .csrf().disable();
        return http.build();
    }

    public Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter jwtConverter = new JwtGrantedAuthoritiesConverter();
        // jwtConverter.setAuthorityPrefix("ROLE_"); 
        jwtConverter.setAuthorityPrefix("");
        jwtConverter.setAuthoritiesClaimName("authorities");
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwtConverter);
        return new ReactiveJwtAuthenticationConverterAdapter(converter);
    }

    /**
     * 권한 없음 (접근 불가)
     */
    public ServerAccessDeniedHandler accessDeniedHandler() {
        return (ServerWebExchange exchange, AccessDeniedException denied) -> {
            Mono<Void> mono = Mono.defer(() -> Mono.just(exchange.getResponse())).flatMap(resp -> ResponseUtils.writeErrorInfo(resp, HttpStatus.UNAUTHORIZED));
            return mono;
        };
    }

    /**
     * 인증되지 않음
     */
    public ServerAuthenticationEntryPoint authenticationEntryPoint() {
        return (ServerWebExchange exchange, AuthenticationException ex) -> {
            Mono<Void> mono = Mono.defer(() -> Mono.just(exchange.getResponse())).flatMap(resp -> ResponseUtils.writeErrorInfo(resp, HttpStatus.FORBIDDEN));
            return mono;
        };
    }

    /**
     * 로컬 공개 키 테스트 (선택사항)
     */
    @SneakyThrows
    @Bean
    public RSAPublicKey rsaPublicKey() {
        Resource resource = new ClassPathResource("public.key");
        InputStream is = resource.getInputStream();
        String publicKeyData = IoUtil.read(is).toString();
        X509EncodedKeySpec keySpec = new X509EncodedKeySpec((Base64.decode(publicKeyData)));
        KeyFactory keyFactory = KeyFactory.getInstance("RSA");
        RSAPublicKey rsaPublicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec);
        return rsaPublicKey;
    }
}

설명: 공개 키는 원격에서 가져올 수 있으며, 로컬에서 읽을 수도 있습니다. 위 코드에서 주석 처리된 부분은 로컬 공개 키를 테스트하는 방법입니다. 소스 코드를 보면 여러 방법이 가능하며, 본 예제에서는 원격에서 가져오는 방식을 사용하므로 application.yml에 spring.security.oauth2.resourceserver.jwt.jwk-set-uri를 설정했습니다.

응답 유틸리티 클래스 ResponseUtils

package com.soa.supervision.gateway.util;

import com.alibaba.fastjson.JSON;
import com.tgf.common.domain.RespResult;
import com.tgf.common.util.RespUtils;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpResponse;
import reactor.core.publisher.Mono;

import java.nio.charset.StandardCharsets;

/**
 * @Author ChengJianSheng 
 * @Date 2022/2/16
 */
public class ResponseUtils {
    public static Mono<Void> writeErrorInfo(ServerHttpResponse response, HttpStatus status) {
        response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
        response.getHeaders().set("Access-Control-Allow-Origin", "*");
        response.getHeaders().set("Cache-Control", "no-cache");
        RespResult result = RespUtils.fail(status.value(), status.getReasonPhrase());
        String body = JSON.toJSONString(result);
        DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8));
        return response.writeWith(Mono.just(buffer)).doOnError(error -> DataBufferUtils.release(buffer));
    }
} 

권한 검증 후 토큰을 해석하여 유용한 정보를 헤더에 추가하여 하위 서비스에 전달할 수 있습니다.

TokenFilter

package com.soa.supervision.gateway.filter;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.nimbusds.jose.JWSObject;
import com.soa.supervision.gateway.constant.AuthConstants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

import java.text.ParseException;

/**
 * 라우팅 규칙에 일치하는 요청 URL에 대해서만 글로벌 필터 실행 
 * 
 * @Author ChengJianSheng
 * @Date 2021/12/15
 */
@Slf4j
@Component
public class TokenFilter implements GlobalFilter {
    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        ServerHttpRequest request = exchange.getRequest();
        String token = request.getHeaders().getFirst(AuthConstants.JWT_TOKEN_HEADER);
        if (StringUtils.isBlank(token)) {
            return chain.filter(exchange);
        }
        String realToken = token.trim().substring(7);
        try {
            JWSObject jwsObject = JWSObject.parse(realToken);
            String payload = jwsObject.getPayload().toString();
            JSONObject jsonObject = JSON.parseObject(payload);
            String userId = jsonObject.getString("userId");
            String deptId = jsonObject.getString("deptId");
            request = request.mutate().header(AuthConstants.HEADER_USER_ID, userId).header(AuthConstants.HEADER_DEPT_ID, deptId).build(); // 전체 Payload를 헤더에 넣을 수 있음 
            exchange.getRequest().mutate().header("user", payload).build();
            exchange = exchange.mutate().request(request).build();
        } catch (ParseException e) {
            log.error("토큰 해석 실패! 원인: {}", e.getMessage(), e);
        }
        return chain.filter(exchange);
    }
}

마지막으로, 몇 가지 상수 클래스

AuthConstants

package com.soa.supervision.gateway.constant;

/**
 * @Author ChengJianSheng 
 * @Date 2021/11/17
 */
public class AuthConstants {
    public static final String ROLE_PREFIX = "ROLE_";
    public static final String JWT_TOKEN_HEADER = "Authorization";
    public static final String JWT_TOKEN_PREFIX = "Bearer ";
    public static final String TOKEN_WHITELIST_PREFIX = "TOKEN:";
    public static final String HEADER_USER_ID = "x-user-id";
    public static final String HEADER_DEPT_ID = "x-dept-id";
}

RedisConstants

package com.soa.supervision.gateway.constant;

/**
 * @Author ChengJianSheng
 * @Date 2022/2/16
 */
public class RedisConstants {
    // 리소스-역할 매핑 
    public static final String URL_ROLE_MAP_HK = "URL_ROLE_HS";
    // 유효한 토큰 
    public static final String ONLINE_TOKEN_PREFIX_KV = "ONLINE_TOKEN:";
}

마지막으로, 데이터베이스 스크립트

DROP TABLE IF EXISTS `sys_menu`;
CREATE TABLE `sys_menu`
(
    `id`          int(11) NOT NULL AUTO_INCREMENT,
    `system_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT '시스템 이름',
    `system_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT '시스템 코드',
    `name`        varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci  NOT NULL COMMENT '메뉴 이름',
    `parent_id`   int(11) NOT NULL COMMENT '상위 메뉴 ID',
    `route_path`  varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '라우트 경로',
    `component`   varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '컴포넌트',
    `icon`        varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '아이콘',
    `sort`        smallint(8) NOT NULL COMMENT '정렬 번호',
    `hidden`      tinyint(4) NOT NULL COMMENT '숨김 여부(1:숨김, 0:표시)',
    `create_time` datetime                                                      NOT NULL COMMENT '생성 시간',
    `update_time` datetime                                                      NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '수정 시간',
    `create_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '생성자',
    `update_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '수정자',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '메뉴 테이블' ROW_FORMAT = DYNAMIC;

DROP TABLE IF EXISTS `sys_permission`;
CREATE TABLE `sys_permission`
(
    `id`          int(11) NOT NULL AUTO_INCREMENT,
    `menu_id`     int(11) NOT NULL COMMENT '메뉴 ID',
    `name`        varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '이름',
    `url`         varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'URL',
    `create_time` datetime NULL DEFAULT NULL COMMENT '생성 시간',
    `update_time` datetime NULL DEFAULT NULL COMMENT '수정 시간',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '권한 테이블' ROW_FORMAT = Dynamic;

DROP TABLE IF EXISTS `sys_role`;
CREATE TABLE `sys_role`
(
    `id`          int(11) NOT NULL AUTO_INCREMENT,
    `name`        varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '역할 이름',
    `code`        varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '역할 코드',
    `sort`        smallint(8) NOT NULL COMMENT '정렬 번호',
    `create_time` datetime                                                     NOT NULL COMMENT '생성 시간',
    `update_time` datetime                                                     NOT NULL COMMENT '수정 시간',
    `create_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '생성자',
    `update_user` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '수정자',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '역할 테이블' ROW_FORMAT = DYNAMIC;

DROP TABLE IF EXISTS `sys_role_menu`;
CREATE TABLE `sys_role_menu`
(
    `id`      int(11) NOT NULL AUTO_INCREMENT,
    `role_id` int(11) NOT NULL COMMENT '역할 ID',
    `menu_id` int(11) NOT NULL COMMENT '메뉴 ID',
    PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '역할-메뉴 테이블' ROW_FORMAT = DYNAMIC;

프로젝트 화면 캡처

  1. 유용한 문서

https://github.com/spring-projects/spring-security/wiki/OAuth-2.0-Migration-Guide

https://docs.spring.io/spring-security-oauth2-boot/docs/current/reference/html5/

https://docs.spring.io/spring-security/reference/index.html

https://github.com/spring-projects/spring-security-samples/tree/5.6.x

https://github.com/spring-projects/spring-security/wiki

https://jwt.io/

https://jwt.io/introduction

태그: Spring Security oauth2 jwt Redis 마이크로서비스

9월 15일 10:47에 게시됨