3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Keycloakで権限制御を動的に行う方法

3
Last updated at Posted at 2025-07-15

目的

「権限情報をソースコードにべた書きしたくない」「Keycloakの設定変更だけで権限を管理したい」そんな要望を実現するため、Keycloak Authorization Servicesを使い、権限制御を動的に行いました。
ハードコーディングされた権限制御から脱却し、keycloakでの権限制御を目指します。

AsIs

// ハードコーディングされた権限制御
const permissions = {
  canViewUsers: userRoles.includes('manager') || userRoles.includes('admin'),
  canViewSales: userRoles.includes('manager') || userRoles.includes('admin'),
  canViewAdmin: userRoles.includes('admin')
};

ToBe

  // 権限情報をkeycloackから取得。リソース名から権限をマッピング
  permissions.canViewUsers = await evaluatePermission(accessToken, 'User Management Pages');
  permissions.canViewUsers = await evaluatePermission(accessToken, 'Sales Management Pages');
  permissions.canViewUsers = await evaluatePermission(accessToken, 'Admin Management Pages');

権限情報をkeycloakのpermissionで管理

スクリーンショット 2025-07-15 100600.png

今回実装したアーキテクチャの概要

認証用のPublicClientと、認可用のConfidentialClientの2つのclientを使用しました。
architecture.png

Resourceとしてフロントエンドの画面を登録します。
スクリーンショット 2025-07-15 102325.png

実装

frontend-app Client(Public Client)

{
 "clientId": "frontend-app",
 "publicClient": true,
 "standardFlowEnabled": true,
 "directAccessGrantsEnabled": false,
 "redirectUris": ["http://localhost:3000/*"],
 "webOrigins": ["http://localhost:3000"]
}

backend-service Client(Confidential Client)

{
  "clientId": "backend-service", 
  "publicClient": false,
  "serviceAccountsEnabled": true,
  "authorizationServicesEnabled": true,
  "authorizationSettings": {
    "resources": [
      {
        "name": "User Management Pages",
        "scopes": ["access"]
      },
      {
        "name": "Sales Management Pages", 
        "scopes": ["access"]
      },
      {
        "name": "Admin Management Pages",
        "scopes": ["access"] 
      }
      ,
      {
        "name": "Report Management Pages",
        "scopes": ["access"] 
      }
    ],
    "policies": [
      
      {
        "name": "Admin Role Policy", 
        "type": "role",
        "logic": "POSITIVE",
        "config": {
          "roles": "[{\"id\":\"admin\",\"required\":false}]"
        }
      },
      {
          "name": "Manager Role Policy",
          "type": "role",
          "logic": "POSITIVE",
          "config": {
            "roles": "[{\"id\":\"manager\",\"required\":false}]"
          }
      },
      {
          "name": "User Role Policy",
          "type": "role",
          "logic": "POSITIVE",
          "config": {
            "roles": "[{\"id\":\"user\",\"required\":false}]"
          }
      }
    ]
  }
}

Permissonsの設定

スコープベースで作成します。
スクリーンショット 2025-07-15 103710.png

フロントエンド

// App.js  
import React, { useState, useEffect } from 'react';
import keycloak from './keycloak';

function App() {
  const [user, setUser] = useState(null);
  const [permissions, setPermissions] = useState(null);

  useEffect(() => {
    keycloak.init({ 
      onLoad: 'check-sso',
      checkLoginIframe: false,
      pkceMethod: 'S256'
    })
      .then(authenticated => {
        if (authenticated && keycloak.token) {
          fetchUserAndPermissions();
        }
      });
  }, []);

  const fetchUserAndPermissions = async () => {
    try {
      // ユーザー情報取得
      const userResponse = await fetch('http://localhost:8081/api/user', {
        headers: {
          'Authorization': `Bearer ${keycloak.token}`
        }
      });
      const userData = await userResponse.json();
      setUser(userData);

      // 権限情報をバックエンドから取得
      const permResponse = await fetch('http://localhost:8081/api/permissions', {
        headers: {
          'Authorization': `Bearer ${keycloak.token}`
        }
      });
      const permData = await permResponse.json();
      setPermissions(permData);

    } catch (error) {
      // エラーハンドリング
    }
  };

  if (!keycloak.authenticated) {
    return (
      <div>
        <h2>ログインが必要です</h2>
        <button onClick={() => keycloak.login()}>
          Keycloakでログイン
        </button>
      </div>
    );
  }

  return (
    <div>
      <h1>Keycloak 権限制御デモ</h1>
      
      {/* ユーザー情報表示 */}
      <div>
        <h3>ユーザー情報</h3>
        <p>ユーザー名: {user?.username}</p>
        <p>ロール: {user?.roles?.join(', ')}</p>
        <button onClick={() => keycloak.logout()}>ログアウト</button>
      </div>

      {/* 権限に基づく画面表示制御 */}
      {permissions && (
        <div>
          <h3>画面表示権限</h3>
          {permissions.canViewUsers && (
            <div>ユーザー管理画面</div>
          )}
          {permissions.canViewSales && (
            <div>営業管理画面</div>
          )}
          {permissions.canViewAdmin && (
            <div>管理者設定画面</div>
          )}
           {permissions.canViewReport && (
            <div>レポート画面</div>
          )}
        </div>
      )}
    </div>
  );
}

export default App;

Node.js Backend

const express = require('express');
const cors = require('cors');
const app = express();

app.use(cors({
  origin: 'http://localhost:3000',
  credentials: true
}));

app.use(express.json());

// JWT認証
function verifyToken(req, res, next) {
  const authHeader = req.headers.authorization;
  
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  const token = authHeader.split(' ')[1];
  
  try {
    const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
    req.user = payload;
    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

// ユーザー情報取得API
app.get('/api/user', verifyToken, (req, res) => {
  res.json({
    username: req.user.preferred_username || req.user.sub,
    roles: req.user.realm_access?.roles || []
  });
});

// Keycloak Authorization Servicesによる権限チェックAPI
app.get('/api/permissions', verifyToken, async (req, res) => {
  try {
    const accessToken = req.headers.authorization.split(' ')[1];
    
    // Admin APIトークン取得
    const adminToken = await getAdminToken();
    
    // Keycloakからリソース情報を取得
    const resources = await getResourcesFromKeycloak(adminToken);
    
    // 各リソースに対する権限を評価
    const permissions = {};
    
    for (const resource of resources) {
      try {
        const hasPermission = await evaluatePermission(accessToken, resource.name);
        
        // 権限情報をkeycloackから取得。リソース名から権限をマッピング
        if (resource.name === 'User Management Pages') {
          permissions.canViewUsers = hasPermission;
        } else if (resource.name === 'Sales Management Pages') {
          permissions.canViewSales = hasPermission;
        } else if (resource.name === 'Admin Management Pages') {
          permissions.canViewAdmin = hasPermission;
        }else if (resource.name === 'Report Management Pages') {
          permissions.canViewReport = hasPermission;
        }
      } catch (evalError) {
        // 評価失敗時は権限なしとする
        if (resource.name === 'User Management Pages') permissions.canViewUsers = false;
        if (resource.name === 'Sales Management Pages') permissions.canViewSales = false;
        if (resource.name === 'Admin Management Pages') permissions.canViewAdmin = false;
        if (resource.name === 'Report Management Pages') permissions.canViewReport = false;
      }
    }
    
    res.json(permissions);
    
  } catch (error) {
    res.json({
      canViewUsers: "false",
      canViewSales: "false",
      canViewAdmin: "false",
      canViewReport: "false",
    });
  }
});

// Admin APIトークン取得
async function getAdminToken() {
  const response = await fetch('http://localhost:8080/realms/master/protocol/openid-connect/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams({
      grant_type: 'password',
      client_id: 'admin-cli',
      username: 'admin',
      password: 'admin'
    })
  });
  
  if (!response.ok) {
    throw new Error(`Admin token request failed: ${response.status}`);
  }
  
  const data = await response.json();
  return data.access_token;
}

// Keycloakからリソース情報を取得
async function getResourcesFromKeycloak(adminToken) {
  // backend-serviceクライアントIDを取得
  const clientsResponse = await fetch('http://localhost:8080/admin/realms/demo/clients', {
    headers: {
      'Authorization': `Bearer ${adminToken}`,
      'Content-Type': 'application/json'
    }
  });
  
  if (!clientsResponse.ok) {
    throw new Error(`Clients request failed: ${clientsResponse.status}`);
  }
  
  const clients = await clientsResponse.json();
  const backendClient = clients.find(c => c.clientId === 'backend-service');
  
  if (!backendClient) {
    throw new Error('backend-service client not found');
  }
  
  // リソース情報を取得
  const response = await fetch(`http://localhost:8080/admin/realms/demo/clients/${backendClient.id}/authz/resource-server/resource`, {
    headers: {
      'Authorization': `Bearer ${adminToken}`,
      'Content-Type': 'application/json'
    }
  });
  
  if (!response.ok) {
    throw new Error(`Resources request failed: ${response.status}`);
  }
  
  return await response.json();
}

// UMA-2.0による権限評価
async function evaluatePermission(accessToken, resourceName) {
  const response = await fetch('http://localhost:8080/realms/demo/protocol/openid-connect/token', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams({
      'grant_type': 'urn:ietf:params:oauth:grant-type:uma-ticket',
      'audience': 'backend-service',
      'permission': `${resourceName}#access`
    })
  });
  
  return response.ok;
}

const PORT = 8081;
app.listen(PORT);

動作確認

adminでログイン
スクリーンショット 2025-07-15 104835.png

managerでログイン
image.png

userでログイン
スクリーンショット 2025-07-15 104947.png

権限追加

新たに、レポート画面だけを見る権限を追加する要件が出てきたとします。
権限の強さは、admin > manager > user > viewer とします。

まずはviewerのロールを追加します。
スクリーンショット 2025-07-15 105302.png

ユーザーの作成を行い、viewerを紐づけておきます。
image.png

ポリシーの作成を行い、admin,manager,user,viewerと紐づけておきます。
image.png

パーミッションの変更を行い、ポリシーをViewer Policyに変更します。
image.png

これで設定が完了しました。

動作確認

adminでログイン
image.png

managerでログイン
image.png

userでログイン
image.png

viewerでログイン
image.png

無事、keycloakの設定変更のみで画面の表示制御をすることができました。

参考資料

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?