6.0 KiB
6.0 KiB
使用 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:
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
2. 在 Entra 注册应用
- Entra 管理中心 → 应用注册 → 新注册。
- 重定向 URI 选 Web,填你的回调,例如:
https://你的服务/oauth/microsoft/callback - 证书和密码 → 新建客户端机密;记录 应用程序(客户端) ID 与机密。
- API 权限:确保可登录并调用 Graph(
User.Read等,与 scope 一致)。
3. Golang 最小示例
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 仅存服务端。