200 lines
6.0 KiB
Markdown
200 lines
6.0 KiB
Markdown
# 使用 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 仅存服务端。
|