首頁 > 軟體

Spring Boot在Web應用中基於JdbcRealm安全驗證過程

2023-02-12 06:01:23

正文

在安全領域,Subject 用來指代與系統互動的實體,可以是使用者、第三方應用等,它是安全認證框架(例如 Shiro)驗證的主題。 Principal 是 Subject 具有的屬性,例如使用者名稱、身份證號、電話號碼、郵箱等任何安全驗證過程中關心的要素。 Primary principal 指能夠唯一區分 Subject 的屬性,例如身份證號碼,論壇系統中的登入名等,通過它可以唯一識別一個 Subject。 Credential 是認證過程中與 Principal 一同提交到系統的資訊,通常是隻有 Subject 知道的加密資訊,例如密碼、PGP Key等。

應用系統或者說安全認證框架驗證一個 Subject 的過程為:

  • Subject 提供 principal(例如使用者名稱)和 credential(例如密碼)
  • 安全認證框架(例如 Shiro)會驗證 Subject 提供的資訊與儲存在應用系統中的資訊(例如儲存在資料庫或者 LDAP 中)是否匹配。 若匹配,則認為 Subject 為合法使用者;否則,為非法使用者。

01-RBAC 基於角色的存取控制

Role-Based Access Control(RBAC)是最普遍的許可權設計模型。 它包含了三個實體:

  • 使用者
  • 角色
  • 許可權

我們定義三張表,來儲存這三個實體:

CREATE TABLE `demo_user` (
  `user_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '編號',
  `username` varchar(20) NOT NULL COMMENT '帳號',
  `password` varchar(32) NOT NULL COMMENT '密碼MD5(密碼+鹽)',
  `locked` tinyint(4) DEFAULT NULL COMMENT '狀態(0:正常,1:鎖定)',
  `ctime` bigint(20) DEFAULT NULL COMMENT '建立時間',
  PRIMARY KEY (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='使用者';
-- 插入兩條資料,表示兩個使用者
INSERT INTO demo_user (user_id, username, password, locked, ctime) 
VALUES (1, 'admin', 'admin', '0', sysdate()),
       (2, 'lihua', 'lihua123', '0', sysdate()),
       (3, 'hanmeimei', 'hanmeimei123', '0', sysdate());
CREATE TABLE `demo_role` (
  `role_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '編號',
  `name` varchar(20) DEFAULT NULL COMMENT '角色名稱',
  `description` varchar(1000) DEFAULT NULL COMMENT '角色描述',
  PRIMARY KEY (`role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色';
-- 插入兩條資料,表示兩個角色
INSERT INTO demo_role(role_id, name, description) 
VALUES (1, 'admin', '管理員'),
       (2, 'user', '普通使用者');
CREATE TABLE `demo_permission` (
  `permission_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '編號',
  `name` varchar(20) DEFAULT NULL COMMENT '名稱',
  `permission_value` varchar(50) DEFAULT NULL COMMENT '許可權值',
  `status` tinyint(4) DEFAULT NULL COMMENT '狀態(0:禁止,1:正常)',
  PRIMARY KEY (`permission_id`)
) ENGINE=InnoDB AUTO_INCREMENT=86 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='許可權';
-- 插入三條資料,表示三種不同的許可權
INSERT INTO demo_permission(permission_id, name, permission_value, status) 
VALUES (1, '新增使用者', 'user:add', 1),
       (2, '刪除使用者', 'user:delete', 1),
       (3, '檢視使用者', 'user:get', 1);

實體之間具有如下的關係:

  • 角色許可權,一對多,一個角色可以具有多個許可權。
  • 使用者角色,一對多,一個使用者可以具有多個角色。
  • 使用者許可權,一對多,一個使用者有多個許可權。許可權的來源有兩種,一類是直接賦予它某些許可權,另一類是通過賦予它多個角色而賦予它角色關聯的許可權。

我們定義三張表,來儲存上述三種關係:

CREATE TABLE `demo_role_permission` (
  `role_permission_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '編號',
  `role_id` int(10) unsigned NOT NULL COMMENT '角色編號',
  `permission_id` int(10) unsigned NOT NULL COMMENT '許可權編號',
  PRIMARY KEY (`role_permission_id`)
) ENGINE=InnoDB AUTO_INCREMENT=129 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色許可權關聯表';
-- 插入四條條資料,admin 具有增、刪、查使用者許可權,user 具有查使用者許可權
INSERT INTO demo_role_permission(role_permission_id, role_id, permission_id) 
VALUES (1, 1, 1),
       (2, 1, 2),
       (3, 1, 3),
       (4, 2, 3);
CREATE TABLE `demo_user_role` (
  `user_role_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '編號',
  `user_id` int(10) unsigned NOT NULL COMMENT '使用者編號',
  `role_id` int(10) DEFAULT NULL COMMENT '角色編號',
  PRIMARY KEY (`user_role_id`)
) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='使用者角色關聯表';
-- 插入三條資料
INSERT INTO demo_user_role (user_role_id, user_id, role_id) 
VALUES (1, 1, 1),
       (2, 2, 2),
       (3, 3, 2);
CREATE TABLE `demo_user_permission` (
  `user_permission_id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '編號',
  `user_id` int(10) unsigned NOT NULL COMMENT '使用者編號',
  `permission_id` int(10) unsigned NOT NULL COMMENT '許可權編號',
  PRIMARY KEY (`user_permission_id`)
) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='使用者許可權關聯表';
-- 插入五條資料 
INSERT INTO demo_user_permission (user_permission_id, user_id, permission_id) 
VALUES (1, 1, 1),
       (2, 1, 2),
       (3, 1, 3),
       (4, 2, 3),
       (5, 3, 3);

02-Shiro 中基於 JdbcRealm 實現使用者認證、授權

Shiro 中 Realm 是負責與應用系統中的許可權模型打交道的元件,所以它也被稱為 Security DAO(Data Access Object)。 Shiro 中 Realm 的型別設計結構圖如下所示:

AuthenticatingRealm 和 AuthorizingRealm 分別實現了認證、授權的整體流程,將如何獲取儲存認證資訊、許可權資訊通過模板方法方式留給派生類去實現:

  • AuthenticatingRealm#doGetAuthenticationInfo,如何獲取系統儲存的認證資訊,例如使用者、密碼等。對應上節中的 demo_user 表。
  • AuthorizingRealm#doGetAuthorizationInfo,如何獲得使用者的角色、許可權資訊,對應上節中的 demo_role、demo_permission 表。

接下來,我詳細分析下 Shiro 提供的一個基於資料庫的實現類 JdbcRealm。 簡化後的 doGetAuthenticationInfo 流程:

protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
    // 使用者提交上來的資訊
    String username = ((UsernamePasswordToken) token).getUsername();
    Connection conn = null;
    SimpleAuthenticationInfo info = null;
    try {
        conn = dataSource.getConnection(); // 資料庫參照
        // 從資料庫中獲取密碼 
        String password = getPasswordForUser(conn, username)[0];    // 關鍵點1
        // 建立驗證結果
        info = new SimpleAuthenticationInfo(username, password.toCharArray(), getName());
    } catch (SQLException e) {
        final String message = "There was a SQL error while authenticating user [" + username + "]";
        throw new AuthenticationException(message, e);
    } finally {
        JdbcUtils.closeConnection(conn);
    }
    return info;
}

簡化後的 doGetAuthorizationInfo 方法:

protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
    // 這裡的 principals 主要為獲得使用者名稱
    String username = (String) getAvailablePrincipal(principals);
    Connection conn = null;
    Set<String> roleNames = null;
    Set<String> permissions = null;
    try {
        conn = dataSource.getConnection();  // 資料庫參照
        // Retrieve roles and permissions from database
        roleNames = getRoleNamesForUser(conn, username);  // 關鍵點2
        if (permissionsLookupEnabled) {   
            permissions = getPermissions(conn, username, roleNames);  // 關鍵點3
        }
    } catch (SQLException e) {
        final String message = "There was a SQL error while authorizing user [" + username + "]";
        throw new AuthorizationException(message, e);
    } finally {
        JdbcUtils.closeConnection(conn);
    }
    // 建立許可權資訊
    SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(roleNames);
    info.setStringPermissions(permissions);
    return info;
}

注:從 JdbcRealm 的原始碼中能夠發現如下幾點:

  • 關鍵點1,需要一個 SQL 語句,從資料庫表中查詢使用者的密碼。結合上節中定義的表,這個 SQL 語句為 SELECT password FROM demo_user WHERE username = ?
  • 關鍵點2,需要從資料庫中查詢使用者的角色,SQL 語句為 SELECT name FROM demo_user_role ur, demo_user u, demo_role r WHERE ur.user_id = u.user_id AND ur.role_id = r.role_id AND u.username = ?
  • 關鍵點3,需要根據角色列表從資料庫中查詢角色具備的許可權集合,SQL 語句為 SELECT permission_value FROM demo_role_permission rp, demo_role r, demo_permission p WHERE rp.role_id = r.role_id AND rp.permission_id = p.permission_id AND r.name = ?
  • 其他點,我們需要一個資料庫參照,即 DataSource 物件。要查詢許可權,需要 permissionsLookupEnabled == true

綜上,我們需要對 JdbcRealm 物件進行設定,使其能夠獲取到我們儲存在資料庫中的資訊。

@Bean
public Realm realm(@Autowired DataSource dataSource) {
    final JdbcRealm jdbcRealm = new JdbcRealm();
    jdbcRealm.setDataSource(dataSource);
    String authQuery = "SELECT password FROM demo_user WHERE username = ?";
    jdbcRealm.setAuthenticationQuery(authQuery);
    jdbcRealm.setPermissionsLookupEnabled(true);
    String roleQuery = "SELECT name FROM demo_user_role ur, demo_user u, demo_role r WHERE ur.user_id = u.user_id AND ur.role_id = r.role_id AND u.username = ?";
    String permissionQuery = "SELECT permission_value FROM demo_role_permission rp, demo_role r, demo_permission p WHERE rp.role_id = r.role_id AND rp.permission_id = p.permission_id AND r.name = ?";
    jdbcRealm.setUserRolesQuery(roleQuery);
    jdbcRealm.setPermissionsQuery(permissionQuery);
    return jdbcRealm;
}

除了使用 JdbcRealm 的方法外,還可以仿照它編寫我們自己的實現。接下來,我將結合 Spring Data JPA 編寫一個 JpaRealm。

public class JpaRealm extends AuthorizingRealm {
    @Autowired
    private DemoUserRepository userRepository;
    @Autowired
    private DemoUserRoleRepository userRoleRepository;
    @Autowired
    private DemoRolePermissionRepository rolePermissionRepository;
    @Autowired
    private DemoPermissionRepository permissionRepository;
    @Autowired
    private DemoRoleRepository roleRepository;
    @Override
    protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
        // 查詢許可權的過程與 JdbcRealm 一樣,只不過使用了 jpa 
        //null usernames are invalid
        if (principals == null) {
            throw new AuthorizationException("PrincipalCollection method argument cannot be null.");
        }
        String username = (String) getAvailablePrincipal(principals);
        final DemoUser user = this.userRepository.findByUsername(username)
                .orElseThrow(() -> new UnknownAccountException("No account found for user [" + username + "]"));
        final List<Integer> roleIds = userRoleRepository.findByUserId(user.getUserId()).stream()
                .map(DemoUserRole::getUserRoleId)
                .collect(Collectors.toList());
        final Set<String> roleNames = roleRepository.findAllById(roleIds).stream()
                .map(DemoRole::getName)
                .collect(Collectors.toSet());
        final List<Integer> permissionIds = rolePermissionRepository.findAllByRoleIdIn(roleIds).stream()
                .map(DemoRolePermission::getPermissionId)
                .collect(Collectors.toList());
        final Set<String> permissions = permissionRepository.findAllById(permissionIds).stream()
                .map(DemoPermission::getPermissionValue)
                .collect(Collectors.toSet());
        final SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(roleNames);
        info.setStringPermissions(permissions);
        return info;
    }
    @Override
    protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
        // 查詢身份資訊的過程與 JdbcRealm 一樣
        UsernamePasswordToken upToken = (UsernamePasswordToken) token;
        String username = upToken.getUsername();
        // Null username is invalid
        if (username == null) {
            throw new AccountException("Null usernames are not allowed by this realm.");
        }
        try {
            final DemoUser user = this.userRepository.findByUsername(username)
                    .orElseThrow(() -> new UnknownAccountException("No account found for user [" + username + "]"));
            SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(username, user.getPassword().toCharArray(), getName());
            return info;
        } catch (Throwable t) {
            final String message = "There was a SQL error while authenticating user [" + username + "]";
            // Rethrow any SQL errors as an authentication exception
            throw new AuthenticationException(message, t);
        }
    }
}

在之前初始化 JdbcRealm 的地方,換成 JpaRealm 就可以了。

@Bean
public Realm jpaRealm() {
    return new JpaRealm();
}

03-整合到 Spring Boot Web 應用中

接下來,我把前兩節的東西整合在一個 Spring Boot Web 應用中,並測試下效果吧。

首先,編寫兩個 Controller 類,以便能夠從瀏覽器或 Postman 中存取它:

@RestController
public class LoginController {
    @GetMapping("/login")
    public String login() {
        return "please login!";
    }
    @GetMapping("/index")
    public String index() {
        final Subject subject = SecurityUtils.getSubject();
        if (subject.isAuthenticated()) {
            final Object principal = subject.getPrincipal();
            return "hello, " + principal;
        }
        return "hello";
    }
}

LoginController 負責處理到 "/login" 和 "/index' 的請求,主要是為了匹配 Shiro 中的 loginUrl 和 successfulUrl。

shiro:
  enabled: true
  web:
    enabled: true
  loginUrl: /login
  successUrl: /index

當未登入使用者存取時,會重定向到 "/login",你可以看到一個請求登入的提示。 登入成功後,會重定向到 "/index" ,並顯示 "hello, ${使用者名稱}" 提示。

注:為了偷懶,我沒有寫登入介面,預設情況下 Shiro 中的 AuthenticatingFilter 會處理到 loginUrl 的 POST 請求,並從中提取 principal 來進行登入驗證。

// org.apache.shiro.web.filter.AccessControlFilter.isLoginRequest
protected boolean isLoginRequest(ServletRequest request, ServletResponse response) {
    return pathsMatch(getLoginUrl(), request);
}

所以,當需要登入時,只需要向 "/login" 傳送一個 POST 請求即可,例如:

curl --location --request POST 'http://localhost:18886/shiro-web/login' 
--form 'username="lihua"' 
--form 'password="lihua123"'

然後,我編寫了另一個 Controller 它主要用來實現對 User 的增刪查操作:

@RestController
public class UserController {
    @Autowired
    private DemoUserRepository userRepository;
    @RequiresPermissions("user:get")
    @GetMapping("/users")
    public List<DemoUser> all() {
        final List<DemoUser> all = userRepository.findAll();
        return all;
    }
    @RequiresPermissions("user:get")
    @GetMapping("/users/{id}")
    public DemoUser one(@PathVariable Integer id) {
        final Optional<DemoUser> byId = userRepository.findById(id);
        return byId.orElse(null);
    }
    @RequiresPermissions("user:add")
    @PostMapping("/users")
    public String add(@RequestBody DemoUser user) {
        userRepository.save(user);
        return "success";
    }
    @RequiresPermissions("user:delete")
    @DeleteMapping("/users/{id}")
    public String delete(@PathVariable Integer id) {
        userRepository.deleteById(id);
        return "success";
    }
}

加上之前的程式碼,所有的元素我們就湊齊了,可以 run 起來檢查一下了。 如果需要完整的原始碼,可以在我的 gitee.com 上下載。

04-總結

今天,我介紹瞭如何使用 Shiro 中提供的 JdbcRealm 實現基於 RBAC 模型的許可權驗證。 之後,又仿照 JdbcRealm 實現了一個基於 JPA 的 Realm 實現,並將它們整合在了一個 Web 應用中進行了驗證。 希望今天的內容能對你有所幫助。

以上就是Spring Boot在Web應用中基於JdbcRealm安全驗證過程的詳細內容,更多關於Spring Boot JdbcRealm安全驗證的資料請關注it145.com其它相關文章!


IT145.com E-mail:sddin#qq.com