package auth import ( "context" "crypto/sha512" "database/sql" "encoding/hex" "encoding/json" "errors" "fmt" "log" "net" "net/http" "regexp" "strings" "sync" "sync/atomic" "time" "github.com/vaughan0/go-ini" "github.com/vektah/gqlparser/v2/gqlerror" "git.sr.ht/~sircmpwn/core-go/client" "git.sr.ht/~sircmpwn/core-go/config" "git.sr.ht/~sircmpwn/core-go/crypto" "git.sr.ht/~sircmpwn/core-go/database" ) var userCtxKey = &contextKey{"user"} type contextKey struct { name string } var ( oauthBearerRegex = regexp.MustCompile(`^[0-9a-f]{32}$`) oauth2BearerRegex = regexp.MustCompile(`^[0-9a-zA-Z_+/]{33,}$`) ) const ( USER_TYPE_PENDING = "UNCONFIRMED" USER_TYPE_USER = "ACTIVE_NON_PAYING" USER_TYPE_ADMIN = "ADMIN" USER_TYPE_SUSPENDED = "SUSPENDED" ) const ( AUTH_OAUTH_LEGACY = "OAUTH_LEGACY" AUTH_OAUTH2 = "OAUTH2" AUTH_COOKIE = "COOKIE" AUTH_INTERNAL = "INTERNAL" AUTH_ANON_INTERNAL = "ANON_INTERNAL" AUTH_WEBHOOK = "WEBHOOK" ) type AuthContext struct { AuthMethod string IPAddress string // Only filled out for non-anonymous authentication UserID int Created time.Time Updated time.Time Username string Email string UserType string URL *string Location *string Bio *string SuspensionNotice *string // Only set for meta.sr.ht-api PGPKey *string // Only filled out if AuthMethod == AUTH_INTERNAL InternalAuth InternalAuth // Only filled out if AuthMethod == AUTH_OAUTH2 or AUTH_WEBHOOK BearerToken *BearerToken Grants Grants TokenHash [64]byte } func (authctx *AuthContext) Access(scope, kind string) error { switch authctx.AuthMethod { case AUTH_INTERNAL, AUTH_ANON_INTERNAL, AUTH_COOKIE: return nil case AUTH_OAUTH_LEGACY: if kind == RO { // Only legacy tokens with "*" scopes ever get this far return nil } else { return fmt.Errorf("Read/write access denied for legacy OAuth token") } case AUTH_WEBHOOK: if kind != RO { return fmt.Errorf("Access to read/write resolver denied for webhook") } fallthrough case AUTH_OAUTH2: if !authctx.Grants.Has(scope, kind) { return fmt.Errorf("Access denied, missing %v:%v grant", scope, kind) } return nil default: panic(fmt.Errorf("Unknown auth method %q for access check", authctx.AuthMethod)) } } func authError(w http.ResponseWriter, reason string, code int) { gqlerr := gqlerror.Errorf("Authentication error: %s", reason) b, err := json.Marshal(struct { Errors []*gqlerror.Error `json:"errors"` }{ Errors: []*gqlerror.Error{gqlerr}, }) if err != nil { panic(err) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) w.Write(b) } func authForUsername(ctx context.Context, username string) (*AuthContext, error) { var auth AuthContext if err := LookupUser(ctx, username, &auth); err != nil { return nil, err } if auth.UserType == USER_TYPE_SUSPENDED { return nil, fmt.Errorf( "Account suspended with the following notice: %s\nContact support", *auth.SuspensionNotice) } return &auth, nil } // NOTE: This only works for meta.sr.ht (should we move it?) func authForOAuthClient(ctx context.Context, clientUUID string) (*AuthContext, error) { var auth AuthContext if err := database.WithTx(ctx, &sql.TxOptions{ Isolation: 0, ReadOnly: true, }, func(tx *sql.Tx) error { var ( err error rows *sql.Rows ) query := database. Select(ctx, []string{ `u.id`, `u.username`, `u.created`, `u.updated`, `u.email`, `u.user_type`, `u.url`, `u.location`, `u.bio`, `u.suspension_notice`, }). From(`"oauth2_client" client`). Join(`"user" u ON u.id = client.owner_id`). Where(`client.client_uuid = ?`, clientUUID). Where(`client.revoked = false`) if rows, err = query.RunWith(tx).Query(); err != nil { panic(err) } defer rows.Close() if !rows.Next() { if err := rows.Err(); err != nil { panic(err) } return fmt.Errorf("Authenticating for unknown client ID %s", clientUUID) } if err := rows.Scan(&auth.UserID, &auth.Username, &auth.Created, &auth.Updated, &auth.Email, &auth.UserType, &auth.URL, &auth.Location, &auth.Bio, &auth.SuspensionNotice); err != nil { panic(err) } if rows.Next() { // TODO: Fetch user info from meta if necessary if err := rows.Err(); err != nil { panic(err) } panic(errors.New("Multiple matching user accounts; invariant broken")) } return nil }); err != nil { return nil, err } if auth.UserType == USER_TYPE_SUSPENDED { return nil, fmt.Errorf( "Account suspended with the following notice: %s\nContact support", *auth.SuspensionNotice) } return &auth, nil } type AuthCookie struct { // The username of the authenticated user Name string `json:"name"` } func cookieAuth(cookie *http.Cookie, w http.ResponseWriter, r *http.Request, next http.Handler) { payload := crypto.DecryptWithoutExpiration([]byte(cookie.Value)) if payload == nil { authError(w, "Invalid authentication cookie", http.StatusForbidden) return } var authCookie AuthCookie if err := json.Unmarshal(payload, &authCookie); err != nil { panic(err) // Programmer error } auth, err := authForUsername(r.Context(), authCookie.Name) if err != nil { authError(w, err.Error(), http.StatusForbidden) return } auth.AuthMethod = AUTH_COOKIE ctx := context.WithValue(r.Context(), userCtxKey, auth) r = r.WithContext(ctx) next.ServeHTTP(w, r) } type InternalAuth struct { // The username of the authenticated user Name string `json:"name"` // An arbitrary identifier for this internal user, e.g. "git.sr.ht" ClientID string `json:"client_id"` // An arbitrary identifier for this internal node, e.g. "us-east-3.git.sr.ht" NodeID string `json:"node_id"` // Only used by specific meta.sr.ht routes OAuthClientUUID string `json:"oauth_client_id,omitempty"` } func internalAuth(payload []byte, w http.ResponseWriter, r *http.Request, next http.Handler) { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } ip := net.ParseIP(host) if ip == nil { panic(fmt.Errorf("Unable to parse remote address")) } if !config.IsInternalIP(ip) { authError(w, fmt.Sprintf("Invalid source IP %s for internal auth", ip), http.StatusUnauthorized) return } payload = crypto.DecryptWithExpiration(payload, 30*time.Second) if payload == nil { authError(w, "Invalid Authorization header (encryption error)", http.StatusForbidden) return } var internalAuth InternalAuth if err := json.Unmarshal(payload, &internalAuth); err != nil { panic(err) // Programmer error } if internalAuth.ClientID == "" || internalAuth.NodeID == "" { authError(w, "Invalid Authorization header (missing Client ID or Node ID)", http.StatusForbidden) } var auth *AuthContext if internalAuth.OAuthClientUUID != "" { auth, err = authForOAuthClient(r.Context(), internalAuth.OAuthClientUUID) if err == nil { auth.AuthMethod = AUTH_INTERNAL } } else if internalAuth.Name != "" { auth, err = authForUsername(r.Context(), internalAuth.Name) if err == nil { auth.AuthMethod = AUTH_INTERNAL } } else { // Using anonymous internal auth. This is only used in one specific // situation: registering for a new account. auth = &AuthContext{} auth.AuthMethod = AUTH_ANON_INTERNAL } if err != nil { authError(w, err.Error(), http.StatusForbidden) return } auth.InternalAuth = internalAuth auth.IPAddress = r.RemoteAddr var route []string for _, val := range r.Header.Values("X-Forwarded-For") { route = append(route, strings.Split(val, ",")...) } for _, val := range route { ip := net.ParseIP(val) if ip == nil { continue } if ip.IsPrivate() { continue } auth.IPAddress = ip.String() break } ctx := context.WithValue(r.Context(), userCtxKey, auth) r = r.WithContext(ctx) next.ServeHTTP(w, r) } func FetchMetaProfile(ctx context.Context, username string, user *AuthContext) error { if config.ServiceName(ctx) == "meta.sr.ht" { panic(errors.New("Cannot fetch profile from ourselves")) } query := client.GraphQLQuery{ Query: ` query { me { id username email url location bio userType } }`, } var result struct { Me struct { ID int `json:"id"` Username string `json:"username"` Email string `json:"email"` URL *string `json:"url"` Location *string `json:"location"` Bio *string `json:"bio"` UserType string `json:"userType"` SuspensionNotice string `json:"suspensionNotice"` } `json:"me"` } if err := client.Do(ctx, username, "meta.sr.ht", query, &result); err != nil { return err } profile := result.Me return database.WithTx(ctx, nil, func(tx *sql.Tx) error { // TODO: Make the database representation consistent with this row := tx.QueryRowContext(ctx, ` INSERT INTO "user" ( created, updated, id, username, email, user_type, url, location, bio, suspension_notice ) VALUES ( NOW() at time zone 'utc', NOW() at time zone 'utc', $1, $2, $3, $4, $5, $6, $7, $8 ) ON CONFLICT DO NOTHING RETURNING id, created, updated, username, email, user_type, url, location, bio, suspension_notice;`, profile.ID, profile.Username, profile.Email, profile.UserType, profile.URL, profile.Location, profile.Bio, profile.SuspensionNotice) // TODO: Register webhooks if err := row.Scan(&user.UserID, &user.Created, &user.Updated, &user.Username, &user.Email, &user.UserType, &user.URL, &user.Location, &user.Bio, &user.SuspensionNotice); err != nil { if err == sql.ErrNoRows { panic(errors.New("Failed to upsert user record from meta.sr.ht")) } return err } return nil }) } func LookupUser(ctx context.Context, username string, user *AuthContext) error { return database.WithTx(ctx, &sql.TxOptions{ Isolation: 0, ReadOnly: true, }, func(tx *sql.Tx) error { var ( err error rows *sql.Rows ) query := database. Select(ctx, []string{ `u.id`, `u.username`, `u.created`, `u.updated`, `u.email`, `u.user_type`, `u.url`, `u.location`, `u.bio`, `u.suspension_notice`, }). From(`"user" u`). Where(`u.username = ?`, username) if config.ServiceName(ctx) == "meta.sr.ht" { query = query. LeftJoin(`pgpkey p ON p.id = u.pgp_key_id`). Column(`p.key`) } if rows, err = query.RunWith(tx).Query(); err != nil { return err } defer rows.Close() if !rows.Next() { if err = rows.Err(); err != nil { return err } return FetchMetaProfile(ctx, username, user) } cols := []interface{}{ &user.UserID, &user.Username, &user.Created, &user.Updated, &user.Email, &user.UserType, &user.URL, &user.Location, &user.Bio, &user.SuspensionNotice, } if config.ServiceName(ctx) == "meta.sr.ht" { cols = append(cols, &user.PGPKey) } if err = rows.Scan(cols...); err != nil { return err } if rows.Next() { if err = rows.Err(); err != nil { return err } panic(errors.New("Multiple users of the same username; invariant broken")) } return nil }) } // Returns true if this token or client ID has been revoked (and therefore // should not be trusted) func LookupTokenRevocation(ctx context.Context, username string, hash [64]byte, clientID string) (bool, error) { query := client.GraphQLQuery{ Query: ` query RevocationStatus($hash: String!, $clientId: String) { tokenRevocationStatus(hash: $hash, clientId: $clientId) }`, Variables: map[string]interface{}{ "hash": hex.EncodeToString(hash[:]), "clientId": clientID, }, } var result struct { RevocationStatus bool `json:"tokenRevocationStatus"` } if err := client.Do(ctx, username, "meta.sr.ht", query, &result); err != nil { return true, err } return result.RevocationStatus, nil } func OAuth2(token string, hash [64]byte, w http.ResponseWriter, r *http.Request, next http.Handler) { var ( auth AuthContext err error res int32 tempErr int32 wg sync.WaitGroup ) wg.Add(2) bt := DecodeBearerToken(token) if bt == nil { authError(w, `Invalid or expired OAuth 2.0 bearer token`, http.StatusForbidden) return } go func() { defer wg.Done() err = LookupUser(r.Context(), bt.Username, &auth) if err != nil { log.Printf("LookupUser: %v", err) atomic.AddInt32(&tempErr, 1) } else { atomic.AddInt32(&res, 1) } }() go func() { defer wg.Done() isRevoked, err := LookupTokenRevocation(r.Context(), bt.Username, hash, bt.ClientID) if err != nil { log.Printf("LookupTokenRevocation: %v", err) atomic.AddInt32(&tempErr, 1) } else if !isRevoked { atomic.AddInt32(&res, 1) } }() wg.Wait() if res != 2 { if tempErr != 0 { authError(w, "Temporary error; try again later", http.StatusInternalServerError) } else { authError(w, "Invalid or expired OAuth 2.0 bearer token", http.StatusForbidden) } return } if auth.UserType == USER_TYPE_SUSPENDED { authError(w, fmt.Sprintf( "Account suspended with the following notice: %s\nContact support", *auth.SuspensionNotice), http.StatusForbidden) return } auth.AuthMethod = AUTH_OAUTH2 auth.BearerToken = bt auth.TokenHash = hash auth.Grants, err = DecodeGrants(r.Context(), bt.Grants) if err != nil { panic(err) // unreachable } ctx := context.WithValue(r.Context(), userCtxKey, &auth) r = r.WithContext(ctx) next.ServeHTTP(w, r) } // TODO: Remove legacy OAuth support func LegacyOAuth(bearer string, hash [64]byte, w http.ResponseWriter, r *http.Request, next http.Handler) { var ( auth AuthContext expires time.Time scopes string ) if err := database.WithTx(r.Context(), &sql.TxOptions{ Isolation: 0, ReadOnly: true, }, func(tx *sql.Tx) error { var ( err error rows *sql.Rows ) query := database. Select(r.Context(), []string{ `ot.expires`, `ot.scopes`, `u.id`, `u.username`, `u.created`, `u.updated`, `u.email`, `u.user_type`, `u.url`, `u.location`, `u.bio`, `u.suspension_notice`, }). From(`oauthtoken ot`). Join(`"user" u ON u.id = ot.user_id`). Where(`ot.token_hash = ?`, bearer) if rows, err = query.RunWith(tx).Query(); err != nil { panic(err) } defer rows.Close() if !rows.Next() { if err := rows.Err(); err != nil { panic(err) } authError(w, "Invalid or expired OAuth token", http.StatusForbidden) return nil } if err := rows.Scan(&expires, &scopes, &auth.UserID, &auth.Username, &auth.Created, &auth.Updated, &auth.Email, &auth.UserType, &auth.URL, &auth.Location, &auth.Bio, &auth.SuspensionNotice); err != nil { panic(err) } if rows.Next() { if err := rows.Err(); err != nil { panic(err) } panic(errors.New("Multiple matching OAuth tokens; invariant broken")) } return nil }); err != nil { panic(err) } if time.Now().UTC().After(expires) { authError(w, "Invalid or expired OAuth token", http.StatusForbidden) return } if auth.UserType == USER_TYPE_SUSPENDED { authError(w, fmt.Sprintf( "Account suspended with the following notice: %s\nContact support", *auth.SuspensionNotice), http.StatusForbidden) return } if scopes != "*" { authError(w, "Presently, OAuth authentication to the GraphQL API is only supported for OAuth tokens with all permissions, namely '*'.", http.StatusForbidden) return } auth.AuthMethod = AUTH_OAUTH_LEGACY ctx := context.WithValue(r.Context(), userCtxKey, &auth) r = r.WithContext(ctx) next.ServeHTTP(w, r) } // Returns an auth context configured for webhook delivery. This auth // configuration is not possible during a normal GraphQL query, and is only // used during webhook execution. // // The "ctx" parameter should be a webhook context, and the "auth" parameter // should be the authentication context from the request which caused the // webhook to be fired. func WebhookAuth(ctx context.Context, auth *AuthContext, tokenHash [64]byte, rawGrants string, clientID *string, expires time.Time) (context.Context, error) { if time.Now().UTC().After(expires) { return nil, fmt.Errorf("The authentication token used to create this webhook has expired") } grants, err := DecodeGrants(ctx, rawGrants) if err != nil { return nil, err } whAuth := *auth whAuth.AuthMethod = AUTH_WEBHOOK whAuth.TokenHash = tokenHash whAuth.Grants = grants whAuth.Grants.ReadOnly = true whAuth.BearerToken = &BearerToken{} if clientID != nil { whAuth.BearerToken.ClientID = *clientID } return context.WithValue(ctx, userCtxKey, &whAuth), nil } func Middleware(conf ini.File, apiconf string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie("sr.ht.unified-login.v1") if err == nil { cookieAuth(cookie, w, r, next) return } auth := r.Header.Get("Authorization") if auth == "" { w.Header().Set("WWW-Authenticate", "Bearer") authError(w, `Authorization header is required. Expected 'Authorization: Bearer [token]'`, http.StatusUnauthorized) return } z := strings.SplitN(auth, " ", 2) if len(z) != 2 { authError(w, "Invalid Authorization header", http.StatusBadRequest) return } var bearer string switch strings.ToLower(z[0]) { case "bearer": token := []byte(z[1]) if oauth2BearerRegex.Match(token) { hash := sha512.Sum512(token) bearer = z[1] OAuth2(bearer, hash, w, r, next) return } if oauthBearerRegex.Match(token) { hash := sha512.Sum512(token) bearer = hex.EncodeToString(hash[:]) LegacyOAuth(bearer, hash, w, r, next) return } authError(w, "Invalid OAuth bearer token", http.StatusBadRequest) return case "internal": payload := []byte(z[1]) internalAuth(payload, w, r, next) return default: authError(w, "Invalid Authorization header", http.StatusBadRequest) return } }) } } func ForContext(ctx context.Context) *AuthContext { raw, ok := ctx.Value(userCtxKey).(*AuthContext) if !ok { panic(errors.New("Invalid authentication context")) } return raw }