シングルサインオン(SSO)の仕組みと実装ガイド

1. Cookieの基礎知識

1.1 Cookieとは

Cookieは、Webサーバーがユーザーのブラウザに送信し、ローカル端末に保存される小規模なテキストデータです。主にユーザー識別やセッション追跡に活用され、暗号化された識別情報や設定値を保持します。

1.2 Cookieの主な用途

  • セッション管理: ログイン状態の維持やオンラインショッピングでのカート機能
  • 個人設定の保存: テーマやレイアウトなどのユーザー偏好設定
  • 行動分析: 閲覧履歴に基づくコンテンツ推薦
  • 自動入力: 「ログイン情報を保存」機能による認証情報の保持
  • ゲーム進捗: オンラインゲームのスコアやステータスの同期

1.3 Cookieの属性詳細

(1) Name/Value: Cookie名と対応する値。認証用途ではアクセストークンが含まれます。

(2) Expires: 有効期限。設定しない場合はセッションCookieとしてブラウザ終了時に削除されます。

(3) Path: Cookieを送信対象とするパスを指定します。

(4) Domain: Cookieの有効ドメイン。サブドメインから親ドメインのCookieへアクセス可能なため、SSO実装に有効ですが、セキュリティリスクにも注意が必要です。

(5) Secure: HTTPS接続時のみCookieを送信するよう制限します。

(6) HttpOnly: JavaScriptによるdocument.cookieからのアクセスを防止し、XSS攻撃によるCookie窃取を防ぎます。

2. Sessionの仕組み

2.1 Sessionとは

Sessionはサーバー側でユーザー状態を保持する仕組みです。複数のページ遷移間で同一ユーザーの操作を連続したセッションとして管理します。

2.2 Sessionの活用例

  • ショッピングカート: 商品追加情報をサーバー側で保持
  • 認証状態: ログイン情報をセッションストレージに保存
  • フォーム入力維持: 長いフォームの入力途中でもデータを保持
  • アクセス制御: ユーザー権限に基づく機能の制限

2.3 Sessionの動作原理

  1. ユーザー初回アクセス時、サーバーが一意なセッションIDを生成
  2. セッションオブジェクトをサーバー上に作成し、セッションIDをCookie経由でクライアントに送信
  3. 以降のリクエストではCookieに含まれるセッションIDを基にサーバーが対応するセッションデータを特定
  4. セッションはタイムアウトまたは明示的な破棄まで維持されます

2.4 Cookieとの比較

項目CookieSession
保存場所クライアント(ブラウザ)サーバー
安全性相対的に脆弱高い
データ容量4KB程度に制限サーバー容量依存で大容量
処理負荷クライアントで処理サーバー負荷が増加

3. シングルサインオン(SSO)の実装

3.1 SSOの基本概念

シングルサインオン(SSO)は、一度の認証で複数の独立したシステムにアクセスできる認証基盤です。例えば、企業のメール、カレンダー、ドキュメント管理など複数のサービスで同一の認証情報を使い回せます。

3.2 SSO全体フロー

  1. ユーザーがアプリケーションに未ログイン状態でアクセス
  2. アプリケーションがSSO認証サーバーにリダイレクト
  3. 認証サーバーでユーザー認証を実行
  4. グローバルセッションをRedisに保存
  5. グローバルトークンをCookieに、一時トークンをパラメータでアプリケーションに返却
  6. アプリケーションが一時トークンを検証し、ユーザー情報を取得
  7. 以降のアクセスではCookieのグローバルトークンで自動認証

3.3 詳細リクエストフロー

3.3.1 初回ログイン

  1. ユーザーがAシステムにアクセス。未ログインを検知しSSOサーバーへリダイレクト
  2. SSOサーバーのログインページで認証情報を入力
  3. 認証成功後、Redisにkey: userId, value: userProfileでセッションを保存
  4. UUIDで生成したグローバルトークンをCookie(key: GSESSIONID, value: globalToken)にセット
  5. Redisにkey: globalToken, value: userIdでマッピングを保存
  6. 有効期限10分の一時トークンをRedisに保存し、パラメータでAシステムに返却
  7. Aシステムが一時トークンを持ってSSO検証APIを呼び出し
  8. SSOサーバーが一時トークンを検証後、グローバルトークンからユーザー情報を取得
  9. ユーザー情報をAシステムに返却し、ログイン完了

3.3.2 2回目以降のアクセス

  1. Bシステムアクセス時、Cookieからグローバルトークンを取得
  2. Redisからユーザー情報を取得し、ログイン済みを確認
  3. 新しい一時トークンを生成してBシステムに返却
  4. Bシステムが一時トークンを検証後、ログイン状態になる

3.3.3 ログアウト

  1. Cookieからグローバルトークンを取得
  2. CookieとRedisのグローバルトークン、ユーザーセッションを削除
  3. 全システムでのログアウトが完了

3.4 実装コード

3.4.1 クライアントアプリケーション (A/Bシステム)

<html>
<head>
    <meta charset="utf-8">
    <title>Aシステム</title>
    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios@0.27.2/dist/axios.min.js"></script>
</head>
<body>
    <div id="app">
        <h1>Aシステム</h1>
        <div v-if="!authenticated">
            ゲスト様、<a @click="redirectToLogin">ログイン</a>してください
        </div>
        <div v-else>
            <span style="color: #27ae60;">{{currentUser.name}}</span>様、ようこそ!
            <br/>
            <button @click="handleLogout">ログアウト</button>
        </div>
    </div>
    
    <script>
    const CONFIG = {
        AUTH_SERVER: 'http://localhost:8090',
        API_BASE: 'http://localhost:8080',
        COOKIE_NAME: 'session_data'
    };
    
    function getQueryParam(name) {
        const urlParams = new URLSearchParams(window.location.search);
        return urlParams.get(name);
    }
    
    function getCookie(name) {
        const value = `; ${document.cookie}`;
        const parts = value.split(`; ${name}=`);
        if (parts.length === 2) return parts.pop().split(';').shift();
    }
    
    function setCookie(name, value, days = 1) {
        const expires = new Date();
        expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
        document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
    }
    
    function deleteCookie(name) {
        document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
    }
    
    new Vue({
        el: '#app',
        data: {
            authenticated: false,
            currentUser: {}
        },
        created() {
            this.checkAuthStatus();
            const authToken = getQueryParam('authToken');
            
            if (!this.authenticated) {
                if (authToken) {
                    this.verifyToken(authToken);
                } else {
                    this.redirectToLogin();
                }
            }
        },
        methods: {
            checkAuthStatus() {
                const sessionData = getCookie(CONFIG.COOKIE_NAME);
                if (sessionData) {
                    try {
                        const userData = JSON.parse(decodeURIComponent(sessionData));
                        if (typeof userData === 'object' && userData.id) {
                            this.authenticated = true;
                            this.currentUser = userData;
                        }
                    } catch (e) {
                        this.authenticated = false;
                    }
                }
            },
            verifyToken(token) {
                axios.post(`${CONFIG.AUTH_SERVER}/validateToken?token=${token}`, {}, { withCredentials: true })
                    .then(res => {
                        if (res.data.code === 200) {
                            this.currentUser = res.data.data;
                            this.authenticated = true;
                            setCookie(CONFIG.COOKIE_NAME, JSON.stringify(this.currentUser));
                            window.location.href = `${CONFIG.API_BASE}/sso-app/index.html`;
                        } else {
                            alert(res.data.message);
                        }
                    });
            },
            redirectToLogin() {
                const returnUrl = encodeURIComponent(window.location.href);
                window.location.href = `${CONFIG.AUTH_SERVER}/auth?redirect=${returnUrl}`;
            },
            handleLogout() {
                axios.post(`${CONFIG.AUTH_SERVER}/terminateSession?userId=${this.currentUser.id}`, {}, { withCredentials: true })
                    .then(() => {
                        deleteCookie(CONFIG.COOKIE_NAME);
                        this.authenticated = false;
                        this.currentUser = {};
                        alert('ログアウトしました');
                    });
            }
        }
    });
    </script>
</body>
</html>

3.4.2 認証サーバーログインページ

<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>統合認証システム</title>
</head>
<body>
    <h2>統合認証サーバー</h2>
    <form action="/performLogin" method="post">
        <input type="text" name="account" placeholder="アカウント" required>
        <input type="password" name="passwd" placeholder="パスワード" required>
        <input type="hidden" name="redirectUrl" th:value="${redirectUrl}">
        <button type="submit">ログイン</button>
    </form>
    <p style="color: red;" th:text="${errorMessage}"></p>
</body>
</html>

3.4.3 認証サーバーバックエンド

package com.sso.server.controller;

import com.sso.server.model.UserAccount;
import com.sso.server.model.UserProfile;
import com.sso.server.service.AccountService;
import com.sso.server.util.*;
import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.UUID;
import java.util.stream.Collectors;

@RestController
public class AuthenticationController {
    
    private final AccountService accountService;
    private final RedisOperator redisOperator;
    
    private static final String USER_SESSION_PREFIX = "session:";
    private static final String GLOBAL_TOKEN_PREFIX = "global_token:";
    private static final String TEMP_TOKEN_PREFIX = "temp_auth:";
    private static final String GLOBAL_SESSION_COOKIE = "GSESSIONID";
    
    public AuthenticationController(AccountService accountService, RedisOperator redisOperator) {
        this.accountService = accountService;
        this.redisOperator = redisOperator;
    }
    
    @GetMapping("/auth")
    public String checkAuthStatus(@RequestParam String redirectUrl, 
                                  HttpServletRequest req, 
                                  HttpServletResponse resp,
                                  Model model) {
        model.addAttribute("redirectUrl", redirectUrl);
        String globalToken = extractCookieValue(req, GLOBAL_SESSION_COOKIE);
        
        if (globalToken != null) {
            String userId = redisOperator.get(GLOBAL_TOKEN_PREFIX + globalToken);
            if (userId != null && redisOperator.exists(USER_SESSION_PREFIX + userId)) {
                String tempToken = generateTempToken();
                return "redirect:" + redirectUrl + "?authToken=" + tempToken;
            }
        }
        return "login";
    }
    
    @PostMapping("/performLogin")
    public String performLogin(@RequestParam String account, 
                               @RequestParam String passwd,
                               @RequestParam String redirectUrl,
                               Model model,
                               HttpServletResponse resp) throws Exception {
        model.addAttribute("redirectUrl", redirectUrl);
        
        UserAccount user = accountService.validateCredentials(account, HashUtil.sha256(passwd));
        if (user == null) {
            model.addAttribute("errorMessage", "認証に失敗しました");
            return "login";
        }
        
        UserProfile profile = new UserProfile();
        BeanUtils.copyProperties(user, profile);
        String sessionId = UUID.randomUUID().toString();
        profile.setSessionId(sessionId);
        
        redisOperator.setex(USER_SESSION_PREFIX + user.getId(), 3600, JsonConverter.toJson(profile));
        
        String globalToken = UUID.randomUUID().toString();
        Cookie cookie = new Cookie(GLOBAL_SESSION_COOKIE, globalToken);
        cookie.setPath("/");
        cookie.setHttpOnly(true);
        resp.addCookie(cookie);
        
        redisOperator.setex(GLOBAL_TOKEN_PREFIX + globalToken, 3600, user.getId());
        
        String tempToken = generateTempToken();
        return "redirect:" + redirectUrl + "?authToken=" + tempToken;
    }
    
    private String generateTempToken() {
        String token = UUID.randomUUID().toString();
        redisOperator.setex(TEMP_TOKEN_PREFIX + token, 600, token);
        return token;
    }
    
    @PostMapping("/validateToken")
    public ApiResponse validateToken(@RequestParam String token,
                                     HttpServletRequest req) {
        String storedToken = redisOperator.get(TEMP_TOKEN_PREFIX + token);
        if (storedToken == null || !storedToken.equals(token)) {
            return ApiResponse.error("無効な認証トークンです");
        }
        
        redisOperator.del(TEMP_TOKEN_PREFIX + token);
        
        String globalToken = extractCookieValue(req, GLOBAL_SESSION_COOKIE);
        if (globalToken == null) {
            return ApiResponse.error("グローバルトークンが見つかりません");
        }
        
        String userId = redisOperator.get(GLOBAL_TOKEN_PREFIX + globalToken);
        String userData = redisOperator.get(USER_SESSION_PREFIX + userId);
        
        if (userData == null) {
            return ApiResponse.error("ユーザーセッションが無効です");
        }
        
        return ApiResponse.success(JsonConverter.fromJson(userData, UserProfile.class));
    }
    
    @PostMapping("/terminateSession")
    public ApiResponse terminateSession(@RequestParam String userId, 
                                        HttpServletRequest req,
                                        HttpServletResponse resp) {
        String globalToken = extractCookieValue(req, GLOBAL_SESSION_COOKIE);
        
        Cookie cookie = new Cookie(GLOBAL_SESSION_COOKIE, null);
        cookie.setPath("/");
        cookie.setMaxAge(0);
        resp.addCookie(cookie);
        
        if (globalToken != null) {
            redisOperator.del(GLOBAL_TOKEN_PREFIX + globalToken);
        }
        redisOperator.del(USER_SESSION_PREFIX + userId);
        
        return ApiResponse.success();
    }
    
    private String extractCookieValue(HttpServletRequest req, String name) {
        Cookie[] cookies = req.getCookies();
        if (cookies == null) return null;
        return Arrays.stream(cookies)
                     .filter(c -> c.getName().equals(name))
                     .map(Cookie::getValue)
                     .findFirst()
                     .orElse(null);
    }
}

この実装では、コンサート会場の入場券をイメージしています。メインの入場券がグローバルトークン(GSESSIONID)、各エリアの限定チケットが一時トークン(authToken)の役割を果たします。

タグ: SSO cookie Session redis vue.js

7月17日 02:08 投稿