355 lines
12 KiB
Go
355 lines
12 KiB
Go
package editor
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.coffeylabs.org/coffey-labs/hotdog-cms/internal/forge"
|
|
)
|
|
|
|
// Signing in is the platform's OAuth authorization-code flow with PKCE.
|
|
// The editor never sees a password. It asks for the person's profile and for
|
|
// repository access, which saving needs (pushing draft branches, opening and
|
|
// merging pull requests); the platform still decides, per repository, what
|
|
// that person may actually do. The state and PKCE verifier travel in a
|
|
// sealed, short-lived cookie rather than in server memory.
|
|
|
|
type endpoints struct{ authorize, token, user, scope string }
|
|
|
|
func (f *ForgeConfig) endpoints() endpoints {
|
|
b := f.base()
|
|
switch f.Kind {
|
|
case forge.GitHub:
|
|
api := "https://api.github.com"
|
|
if !strings.EqualFold(f.Host, "github.com") {
|
|
api = b + "/api/v3"
|
|
}
|
|
return endpoints{b + "/login/oauth/authorize", b + "/login/oauth/access_token", api + "/user", "read:user repo"}
|
|
case forge.GitLab:
|
|
return endpoints{b + "/oauth/authorize", b + "/oauth/token", b + "/api/v4/user", "read_user api"}
|
|
case forge.Bitbucket:
|
|
return endpoints{"https://bitbucket.org/site/oauth2/authorize", "https://bitbucket.org/site/oauth2/access_token", "https://api.bitbucket.org/2.0/user", ""}
|
|
default: // Gitea, Forgejo
|
|
return endpoints{b + "/login/oauth/authorize", b + "/login/oauth/access_token", b + "/api/v1/user", "read:user write:repository"}
|
|
}
|
|
}
|
|
|
|
type oauthState struct {
|
|
Forge string `json:"f"`
|
|
State string `json:"s"`
|
|
Verifier string `json:"v"`
|
|
Return string `json:"r"`
|
|
Expires time.Time `json:"e"`
|
|
}
|
|
|
|
func randomString(n int) string {
|
|
b := make([]byte, n)
|
|
_, _ = rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func (s *Server) redirectURI() string { return s.cfg.PublicURL + "/auth/callback" }
|
|
|
|
// login sends the browser to the platform's consent page.
|
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|
f := s.cfg.forge(r.URL.Query().Get("forge"))
|
|
if f == nil {
|
|
http.Error(w, "no such platform here", http.StatusBadRequest)
|
|
return
|
|
}
|
|
st := oauthState{Forge: f.Host, State: randomString(24), Verifier: randomString(48), Return: safeReturn(r.URL.Query().Get("return")), Expires: time.Now().Add(10 * time.Minute)}
|
|
if err := s.seal.setCookie(w, stateCookie, st, 10*time.Minute); err != nil {
|
|
http.Error(w, "couldn't start sign-in", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
sum := sha256.Sum256([]byte(st.Verifier))
|
|
q := url.Values{
|
|
"client_id": {f.ClientID}, "redirect_uri": {s.redirectURI()}, "response_type": {"code"},
|
|
"state": {st.State}, "code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])}, "code_challenge_method": {"S256"},
|
|
}
|
|
if sc := f.endpoints().scope; sc != "" {
|
|
q.Set("scope", sc)
|
|
}
|
|
// Adding a second account on a platform: GitHub can ask which account to
|
|
// use. Elsewhere the platform signs in whoever is signed in there.
|
|
if r.URL.Query().Get("select") == "1" && f.Kind == forge.GitHub {
|
|
q.Set("prompt", "select_account")
|
|
}
|
|
http.Redirect(w, r, f.endpoints().authorize+"?"+q.Encode(), http.StatusFound)
|
|
}
|
|
|
|
// safeReturn keeps the after-sign-in destination on the editor itself.
|
|
func safeReturn(p string) string {
|
|
// A path on the editor itself, nothing else. Browsers drop tabs and
|
|
// newlines from addresses and read a backslash as a slash, so "/\t/x"
|
|
// would become "//x", another site: refuse every control character,
|
|
// space and backslash before looking at the shape.
|
|
for _, r := range p {
|
|
if r < 0x21 || r == 0x7f || r == '\\' {
|
|
return "/"
|
|
}
|
|
}
|
|
u, err := url.Parse(p)
|
|
if err != nil || u.Scheme != "" || u.Host != "" || u.User != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
|
|
return "/"
|
|
}
|
|
return p
|
|
}
|
|
|
|
// callback finishes sign-in: checks state, trades the code for a token,
|
|
// asks the platform who this is, and seals it all into the session cookie.
|
|
func (s *Server) callback(w http.ResponseWriter, r *http.Request) {
|
|
c, err := s.seal.cookie(r, stateCookie)
|
|
var st oauthState
|
|
if err != nil || s.seal.open(stateCookie, c.Value, &st) != nil || time.Now().After(st.Expires) {
|
|
http.Error(w, "Sign-in expired or was started elsewhere. Start again from the editor.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
s.seal.clearCookie(w, stateCookie)
|
|
q := r.URL.Query()
|
|
if e := q.Get("error"); e != "" {
|
|
http.Error(w, "The platform declined the sign-in: "+e, http.StatusForbidden)
|
|
return
|
|
}
|
|
if q.Get("state") == "" || q.Get("state") != st.State {
|
|
http.Error(w, "Sign-in state didn't match. Start again from the editor.", http.StatusBadRequest)
|
|
return
|
|
}
|
|
f := s.cfg.forge(st.Forge)
|
|
if f == nil {
|
|
http.Error(w, "no such platform here", http.StatusBadRequest)
|
|
return
|
|
}
|
|
ts, err := s.exchange(r.Context(), f, q.Get("code"), st.Verifier)
|
|
token := ts.Access
|
|
if err != nil {
|
|
s.log.Printf("sign-in with %s: %v", f.Host, err)
|
|
http.Error(w, "The platform didn't accept the sign-in. Try again.", http.StatusBadGateway)
|
|
return
|
|
}
|
|
login, name, email, err := s.whoami(r.Context(), f, token)
|
|
if err != nil {
|
|
s.log.Printf("sign-in with %s: %v", f.Host, err)
|
|
http.Error(w, "Couldn't read your profile from the platform.", http.StatusBadGateway)
|
|
return
|
|
}
|
|
sess := Session{Forge: f.Host, Login: login, Name: name, Email: email, Token: token, Expires: time.Now().Add(sessionTTL), Refresh: ts.Refresh, TokenExp: ts.Expires}
|
|
// The same account again refreshes its cookie; a new one takes a free slot.
|
|
slot := -1
|
|
used := map[int]bool{}
|
|
for _, a := range s.seal.accounts(r) {
|
|
used[a.slot] = true
|
|
if a.Forge == sess.Forge && a.Login == sess.Login {
|
|
slot = a.slot
|
|
}
|
|
}
|
|
for i := 0; i < maxAccounts && slot < 0; i++ {
|
|
if !used[i] {
|
|
slot = i
|
|
}
|
|
}
|
|
if slot < 0 {
|
|
http.Error(w, fmt.Sprintf("You're signed in to %d accounts, the most the editor holds. Sign one out, then add this one.", maxAccounts), http.StatusConflict)
|
|
return
|
|
}
|
|
if err := s.seal.setCookie(w, accountCookie(slot), sess, sessionTTL); err != nil {
|
|
http.Error(w, "couldn't start the session", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
s.log.Printf("%s signed in with %s", login, f.Host)
|
|
http.Redirect(w, r, st.Return, http.StatusFound)
|
|
}
|
|
|
|
// tokenSet is what a platform's token endpoint hands back.
|
|
type tokenSet struct {
|
|
Access string
|
|
Refresh string
|
|
Expires time.Time // zero when the token doesn't expire
|
|
}
|
|
|
|
func (s *Server) exchange(ctx context.Context, f *ForgeConfig, code, verifier string) (tokenSet, error) {
|
|
return s.tokenRequest(ctx, f, url.Values{"grant_type": {"authorization_code"}, "code": {code}, "redirect_uri": {s.redirectURI()}, "code_verifier": {verifier}})
|
|
}
|
|
|
|
// renew trades a refresh token for a new access token.
|
|
func (s *Server) renew(ctx context.Context, f *ForgeConfig, refresh string) (tokenSet, error) {
|
|
return s.tokenRequest(ctx, f, url.Values{"grant_type": {"refresh_token"}, "refresh_token": {refresh}})
|
|
}
|
|
|
|
func (s *Server) tokenRequest(ctx context.Context, f *ForgeConfig, form url.Values) (tokenSet, error) {
|
|
secret := os.Getenv(f.ClientSecretEnv)
|
|
if secret == "" {
|
|
return tokenSet{}, fmt.Errorf("%s is not set", f.ClientSecretEnv)
|
|
}
|
|
form.Set("client_id", f.ClientID)
|
|
if f.Kind != forge.Bitbucket {
|
|
form.Set("client_secret", secret)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.endpoints().token, strings.NewReader(form.Encode()))
|
|
if err != nil {
|
|
return tokenSet{}, err
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
req.Header.Set("Accept", "application/json")
|
|
if f.Kind == forge.Bitbucket {
|
|
req.SetBasicAuth(f.ClientID, secret)
|
|
}
|
|
res, err := s.http.Do(req)
|
|
if err != nil {
|
|
return tokenSet{}, err
|
|
}
|
|
defer res.Body.Close()
|
|
var d struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
ExpiresIn int64 `json:"expires_in"`
|
|
Error string `json:"error"`
|
|
Description string `json:"error_description"`
|
|
}
|
|
body, _ := io.ReadAll(io.LimitReader(res.Body, 1<<20))
|
|
if err := json.Unmarshal(body, &d); err != nil {
|
|
return tokenSet{}, fmt.Errorf("token response: %s", res.Status)
|
|
}
|
|
if d.AccessToken == "" {
|
|
return tokenSet{}, fmt.Errorf("no token: %s %s", d.Error, d.Description)
|
|
}
|
|
ts := tokenSet{Access: d.AccessToken, Refresh: d.RefreshToken}
|
|
if d.ExpiresIn > 0 {
|
|
ts.Expires = time.Now().Add(time.Duration(d.ExpiresIn) * time.Second)
|
|
}
|
|
return ts, nil
|
|
}
|
|
|
|
// refreshAhead is how long before a token expires it's renewed.
|
|
const refreshAhead = 2 * time.Minute
|
|
|
|
type renewal struct {
|
|
ts tokenSet
|
|
err error
|
|
at time.Time
|
|
}
|
|
|
|
// freshen renews any account's token that is about to expire, and updates
|
|
// its cookie. Requests that arrive together with the same refresh token share
|
|
// one renewal, since some platforms accept a refresh token only once. An
|
|
// account whose renewal is refused is signed out; the others stay.
|
|
func (s *Server) freshen(w http.ResponseWriter, r *http.Request, accts []*Session) []*Session {
|
|
out := accts[:0:0]
|
|
for _, a := range accts {
|
|
if a.TokenExp.IsZero() || time.Until(a.TokenExp) > refreshAhead || a.Refresh == "" {
|
|
out = append(out, a)
|
|
continue
|
|
}
|
|
f := s.cfg.forge(a.Forge)
|
|
if f == nil {
|
|
continue
|
|
}
|
|
s.renewMu.Lock()
|
|
got, ok := s.renewed[a.Refresh]
|
|
if !ok || time.Since(got.at) > time.Minute {
|
|
ts, err := s.renew(r.Context(), f, a.Refresh)
|
|
got = renewal{ts: ts, err: err, at: time.Now()}
|
|
if s.renewed == nil || len(s.renewed) > 1024 {
|
|
s.renewed = map[string]renewal{}
|
|
}
|
|
s.renewed[a.Refresh] = got
|
|
}
|
|
s.renewMu.Unlock()
|
|
if got.err != nil {
|
|
s.log.Printf("%s on %s: renewing the token failed, signing out that account: %v", a.Login, a.Forge, got.err)
|
|
s.seal.clearCookie(w, accountCookie(a.slot))
|
|
continue
|
|
}
|
|
a.Token, a.TokenExp = got.ts.Access, got.ts.Expires
|
|
if got.ts.Refresh != "" {
|
|
a.Refresh = got.ts.Refresh
|
|
}
|
|
_ = s.seal.setCookie(w, accountCookie(a.slot), a, time.Until(a.Expires))
|
|
out = append(out, a)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) whoami(ctx context.Context, f *ForgeConfig, token string) (login, name, email string, err error) {
|
|
var d struct {
|
|
Login string `json:"login"`
|
|
Username string `json:"username"`
|
|
Nickname string `json:"nickname"`
|
|
Name string `json:"name"`
|
|
FullName string `json:"full_name"`
|
|
DisplayName string `json:"display_name"`
|
|
Email string `json:"email"`
|
|
ID int64 `json:"id"`
|
|
}
|
|
if err := s.api(ctx, f, token, f.endpoints().user, &d); err != nil {
|
|
return "", "", "", err
|
|
}
|
|
login = firstNonEmpty(d.Login, d.Username, d.Nickname)
|
|
name = firstNonEmpty(d.FullName, d.Name, d.DisplayName, login)
|
|
if login == "" {
|
|
return "", "", "", errors.New("the platform didn't say who you are")
|
|
}
|
|
// The address commits are attributed to: the one the platform shows, or
|
|
// a no-reply address on the platform when the person keeps theirs private.
|
|
email = d.Email
|
|
if email == "" {
|
|
if f.Kind == forge.GitHub {
|
|
// GitHub's no-reply form with the account's number, which it
|
|
// ties to the account even after a rename.
|
|
email = login + "@users.noreply.github.com"
|
|
if d.ID > 0 {
|
|
email = strconv.FormatInt(d.ID, 10) + "+" + email
|
|
}
|
|
} else {
|
|
email = login + "@noreply." + f.Host
|
|
}
|
|
}
|
|
return login, name, email, nil
|
|
}
|
|
|
|
func firstNonEmpty(v ...string) string {
|
|
for _, s := range v {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// api GETs a platform API URL as the signed-in person and decodes JSON.
|
|
func (s *Server) api(ctx context.Context, f *ForgeConfig, token, u string, v any) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
req.Header.Set("Accept", "application/json")
|
|
req.Header.Set("User-Agent", "hotdog-cms-editor")
|
|
res, err := s.http.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer res.Body.Close()
|
|
if res.StatusCode == http.StatusUnauthorized {
|
|
return errSignedOut
|
|
}
|
|
if res.StatusCode >= 300 {
|
|
return fmt.Errorf("%s: %s", u, res.Status)
|
|
}
|
|
return json.NewDecoder(io.LimitReader(res.Body, 8<<20)).Decode(v)
|
|
}
|
|
|
|
var errSignedOut = errors.New("the platform no longer accepts this session")
|