# 使用 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 仅存服务端。