/微软SSO认证

Created Mon, 12 Jun 2023 15:25:25 +0900

申请接入微软SSO(隐式)大概需要以下流程:

  1. 申请一个Azure AD。
  2. 申请一个新的应用程序注册&&填写配置。
  3. 书写前端(本次使用了官方的react示例来实现)
  4. 书写后端(本次采用了go后端)

OAuth登录流程

  • 相当于是把微软发行的id_token代替了我们的账号系统中的账户密码来进行登录。

分步说明(实操举例)

申请一个Azure AD

没什么好说的,直接进入AzureAD官网(azure.microsoft.com)按照流程申请一个就行了。

申请一个新的应用程序注册

  • step1:进入Azure AD页面

  • step2:应用注册→新注册

  • step3:名字随便填;支持的账户可以先选最宽广的;选SPA;重定向URI先填个base的。

  • step4:多添加一条的重定向url(localhost:3000/redirect)。添加这个URI的原因是因为之后会用到官方的前端示例,实际上可以根据自己的项目要求添加重定向URI。

  • step5:增加API权限。API权限→添加权限→选择MicrosoftGraph→委托的权限→添加如图上的所有权限(之后可根据自己的了解和需求增减)

插曲:确认你的clientID(第一个红框)和tenantID(第二个红框)

书写前端(本次使用了官方的react示例来实现)

说明书:[前端react配置说明书]

step0:安装npm和git(建议chatgpt查看如何安装)
step1:克隆代码库
git clone https://github.com/Azure-Samples/ms-identity-javascript-react-tutorial.git
step2:移动到项目启动目录里
cd 1-Authentication\1-sign-in\SPA
step3:修改配置文件(参考下图)
vim src\authConfig.js
export const msalConfig = {
    auth: {
        clientId: '9ea52bac-f416-43cf-ad6e-88dffe7d0b1e',
        authority: 'https://login.microsoftonline.com/9e55725a-626e-4339-bcf7-2b3e915426fa', 
        ...
    }
    ...
}
  • clientId填clientId,authority的url最后的path改为tenantID。
step4:运行程序
npm start
step5:login获取token
  • 需要用f12抓token的请求中的id_token,后端直接用。(因为用的官方提供的SPA,并没有前后端连起来,到时候手动把id_token传过去)

书写后端(go)

后端的目的:从id_token(JWT形式)里面获取到信息,并且使用微软颁布的公钥去验证JWT是否被篡改。当JWT验证成功时,再颁发自己系统的登录token就可以了。 相当于用微软颁发的id_token中的信息来充当账号,id_token合法充当密码。其他一切照旧无需改动。

go项目的依赖环境准备

# go项目中运行(我用的是golang直接新建项目,所以跳过了初始化项目的步骤)
go get -u github.com/dgrijalva/jwt-go
go get -u github.com/gin-gonic/gin
go get -u github.com/lestrrat-go/jwx
go get github.com/lestrrat-go/backoff/v2
go get github.com/lestrrat-go/jwx/[email protected]

书写后端程序逻辑

我们需要做的是:

  1. 写一个路由入口接收id_token(gin框架书写)
  2. 对id_token进行验证(取公钥→用公钥验证jwt签名部分+token的颁发时间是否合规等)
  3. 从验证成功的id_token中提取出我们所需要的信息(email)
package main

import (
    "context"
    "crypto/rsa"
    "encoding/json"
    "fmt"
    "github.com/dgrijalva/jwt-go"
    "github.com/gin-gonic/gin"
    "net/http"

    "github.com/lestrrat-go/jwx/jwa"
    "github.com/lestrrat-go/jwx/jwk"
)

// 接收json格式的body请求体用
type OAuthLoginRequest struct {
    AccessToken string `form:"access_token" json:"access_token"`
    IdToken     string `form:"id_token" json:"id_token" binding:"required"`
}

// 写你的tenantID
var tenantID = "9e55725a-626e-4339-bcf7-2b3e915426fa"

// 验证token是否合法并且返回email.
// OAuthVerify
/* step1:从https://login.microsoftonline.com/[tenantID]/discovery/v2.0/keys中获取所有该tenantID的jwk公钥
   step2从id_token中的第一部分中找到kid字段的values,该值定义了使用的是哪个公钥
   step3:验证通过后,把id_token中的第二部分转换为map,然后提取出里面的email部分
   step4:返回结果

 */
func (O *OAuthLoginRequest) OAuthVerify() (int, string, string) {
    // step1
    keySet, err := jwk.Fetch(context.Background(),
        fmt.Sprintf("https://login.microsoftonline.com/%s/discovery/v2.0/keys", tenantID))
    if err != nil {
        return 10001, "failed to retrieve the public key from Microsoft", ""
    }

    // step2
    token, err := jwt.Parse(O.IdToken, func(token *jwt.Token) (interface{}, error) {
        // check Encryption method
        if token.Method.Alg() != jwa.RS256.String() {
            return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
        }
        // check if the path in keySet
        kid, ok := token.Header["kid"].(string)
        if !ok {
            return nil, fmt.Errorf("kid header not found")
        }

        // check if the kid in keySet
        keys, ok := keySet.LookupKeyID(kid)
        if !ok {
            return nil, fmt.Errorf("key %v not found", kid)
        }

        publicKey := &rsa.PublicKey{}
        err = keys.Raw(publicKey)
        if err != nil {
            return nil, fmt.Errorf("could not parse pubkey")
        }
        return publicKey, nil
    })
    if err != nil {
        return 10001, err.Error(), ""
    }

    if !token.Valid && func() bool {
        if err = token.Claims.Valid(); err != nil {
            return false
        }
        return true
    }() {
        return 10001, "Token verification failed.", ""
    }
    // step3
    email, errMsg := GetEmailFromToken(token)
    if errMsg != nil {
        return 10001, errMsg.Error(), ""
    }
    // step4
    return 0, "Token verification successes", email
}

// 控制器入口
func OauthLogin(c *gin.Context) {
    var reqData OAuthLoginRequest
    if err := c.ShouldBindJSON(&reqData); err != nil {
        c.JSON(http.StatusBadRequest, gin.H{"status": 7,
            "msg": "The submitted form data is incorrect. Please verify and resubmit.", "data": map[string]any{}})
        return
    }
    var email string
    if err, msg, temp := reqData.OAuthVerify(); err != 0 {
        c.JSON(http.StatusBadRequest, gin.H{"status": err, "msg": msg, "data": map[string]any{}})
        return
    } else {
        email = temp
    }
    if err, msg, temp := reqData.OAuthVerify(); err != 0 {
        c.JSON(http.StatusBadRequest, gin.H{"status": err, "msg": msg, "data": map[string]any{}})
        return
    } else {
        email = temp
    }
    // fmt.Print(email)
    
    // 当上面的email成功打印出来,证明id_token验证成功了,你可以直接发行自己账号系统的token了。
    // TODO 绕过密码发行token
    ...
    c.JSON(http.StatusBadRequest, gin.H{"status": http.StatusOK, "msg": email, "data": map[string]any{}})
    return
}

// 从id_token中提取email
func GetEmailFromToken(token *jwt.Token) (string, error) {
    // 1.claims to Json
    claimsValue, err := json.Marshal(token.Claims)
    if err != nil {
        return "", fmt.Errorf("token parse failed(1)")
    }
    // 2.Json to map
    var claimsMap map[string]interface{}
    if jsonErr := json.Unmarshal(claimsValue, &claimsMap); jsonErr != nil {
        return "", fmt.Errorf("token parse failed(2)")
    }
    // 3.Get email from map
    email, ok := claimsMap["preferred_username"].(string)
    if !ok {
        return "", fmt.Errorf("token parse failed(3)")
    }
    return email, nil
}

func main() {
    r := gin.Default()
    r.POST("/Oauth/Login", OauthLogin)
    r.Run(":8000")
}

结尾

没什么好说的,不建议用这种方式,最好还是用OAuth2.0的授权码流程验证。


参考:

1.USING AZURE AD AUTHENTICATION FOR A GO API

2.微软官方例子