Files
SproutGate/docs/oauth-providers/microsoft.md
2026-05-13 12:19:36 +08:00

200 lines
6.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 使用 Microsoft / Entra ID 登录OAuth 2.0 接入指南Golang
本文介绍通过 **Microsoft 身份平台v2.0** 使用个人或工作/学校账号登录:在 **Microsoft Entra原 Azure AD** 注册应用,走授权码流程,再用 **Microsoft Graph** 读取用户资料。与具体业务系统无关。
## 1. 协议与端点
v2.0 形式(`{tenant}` 可为 `common`、组织租户 ID、`consumers` 等,依你的「支持的帐户类型」选择):
| 步骤 | URL |
|------|-----|
| 授权 | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` |
| 换 token | `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` |
| 当前用户 | `GET https://graph.microsoft.com/v1.0/me``Authorization: Bearer {token}` |
Go 中手动拼 `oauth2.Endpoint`
```go
func microsoftV2Endpoint(tenant string) oauth2.Endpoint {
if tenant == "" {
tenant = "common"
}
base := "https://login.microsoftonline.com/" + tenant
return oauth2.Endpoint{
AuthURL: base + "/oauth2/v2.0/authorize",
TokenURL: base + "/oauth2/v2.0/token",
}
}
```
Scope 至少包含:`openid``profile``email``https://graph.microsoft.com/User.Read`(读 `/me`)。
官方文档:[Microsoft identity platform and OAuth 2.0 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow)
## 2. 在 Entra 注册应用
1. [Entra 管理中心](https://entra.microsoft.com/) → **应用注册****新注册**
2. **重定向 URI****Web**,填你的回调,例如:
`https://你的服务/oauth/microsoft/callback`
3. **证书和密码** → 新建客户端机密;记录 **应用程序(客户端) ID** 与机密。
4. **API 权限**:确保可登录并调用 Graph`User.Read` 等,与 scope 一致)。
## 3. Golang 最小示例
```go
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"time"
"golang.org/x/oauth2"
)
func main() {
tenant := getenv("MS_TENANT", "common")
redirectURL := getenv("OAUTH_REDIRECT_URL", "http://127.0.0.1:8080/oauth/microsoft/callback")
cfg := &oauth2.Config{
ClientID: os.Getenv("MS_CLIENT_ID"),
ClientSecret: os.Getenv("MS_CLIENT_SECRET"),
RedirectURL: redirectURL,
Scopes: []string{
"openid", "profile", "email",
"https://graph.microsoft.com/User.Read",
},
Endpoint: microsoftV2Endpoint(tenant),
}
if cfg.ClientID == "" || cfg.ClientSecret == "" {
log.Fatal("set MS_CLIENT_ID and MS_CLIENT_SECRET")
}
states := newStateStore()
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
b := make([]byte, 16)
_, _ = rand.Read(b)
state := base64.RawURLEncoding.EncodeToString(b)
states.Put(state, time.Now().Add(10*time.Minute))
http.Redirect(w, r, cfg.AuthCodeURL(state), http.StatusFound)
})
http.HandleFunc("/oauth/microsoft/callback", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("error") != "" {
http.Error(w, r.URL.Query().Get("error_description"), http.StatusBadRequest)
return
}
if !states.Consume(r.URL.Query().Get("state")) {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
ctx := r.Context()
tok, err := cfg.Exchange(ctx, r.URL.Query().Get("code"))
if err != nil {
http.Error(w, "exchange: "+err.Error(), http.StatusBadRequest)
return
}
u, err := fetchGraphMe(ctx, tok.AccessToken)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(u)
})
log.Println("Entra redirect URI must match:", redirectURL)
log.Fatal(http.ListenAndServe(":8080", nil))
}
type graphMe struct {
ID string `json:"id"`
Mail string `json:"mail"`
UserPrincipalName string `json:"userPrincipalName"`
DisplayName string `json:"displayName"`
}
func fetchGraphMe(ctx context.Context, access string) (*graphMe, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet,
"https://graph.microsoft.com/v1.0/me", nil)
req.Header.Set("Authorization", "Bearer "+access)
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graph: %d %s", res.StatusCode, b)
}
var u graphMe
if err := json.Unmarshal(b, &u); err != nil {
return nil, err
}
return &u, nil
}
func microsoftV2Endpoint(tenant string) oauth2.Endpoint {
if tenant == "" {
tenant = "common"
}
base := "https://login.microsoftonline.com/" + tenant
return oauth2.Endpoint{
AuthURL: base + "/oauth2/v2.0/authorize",
TokenURL: base + "/oauth2/v2.0/token",
}
}
type stateStore struct {
mu sync.Mutex
m map[string]time.Time
}
func newStateStore() *stateStore { return &stateStore{m: make(map[string]time.Time)} }
func (s *stateStore) Put(k string, exp time.Time) {
s.mu.Lock()
s.m[k] = exp
s.mu.Unlock()
}
func (s *stateStore) Consume(k string) bool {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.m[k]
if !ok || time.Now().After(t) {
return false
}
delete(s.m, k)
return true
}
func getenv(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
```
## 4. 租户 `common` 与单租户
- **`common`**:适合「多组织 + 个人账户」类应用;与 Entra 注册时「支持的帐户类型」需一致。
- **单一组织**:将 `MS_TENANT` 设为**目录租户 ID**,并在应用注册中选择仅本组织,减少误登范围。
## 5. 常见错误
- **换 token 失败**`redirect_uri` 与注册应用时不一致;或 Secret 错误。
- **Graph 403**:未授予 `User.Read` 或 token 中无对应 scope。
## 6. 安全建议
- 使用返回的 **用户 `id`GUID** 作为外部账号主键。
- `state` 防重放Secret 仅存服务端。