lavender/server/login.go

226 lines
6.2 KiB
Go

package server
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"github.com/1f349/lavender/database"
"github.com/1f349/lavender/issuer"
"github.com/1f349/lavender/pages"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"golang.org/x/oauth2"
"net/http"
"net/url"
"strings"
"time"
)
func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
if !auth.IsGuest() {
h.SafeRedirect(rw, req)
return
}
cookie, err := req.Cookie("lavender-login-name")
if err == nil && cookie.Valid() == nil {
pages.RenderPageTemplate(rw, "login-memory", map[string]any{
"ServiceName": h.conf.ServiceName,
"LoginName": cookie.Value,
})
return
}
pages.RenderPageTemplate(rw, "login", map[string]any{
"ServiceName": h.conf.ServiceName,
})
}
func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
if !auth.IsGuest() {
h.SafeRedirect(rw, req)
return
}
if req.PostFormValue("not-you") == "1" {
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: "",
Path: "/",
MaxAge: -1,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
http.Redirect(rw, req, (&url.URL{
Path: "/login",
}).String(), http.StatusFound)
return
}
loginName := req.PostFormValue("loginname")
login := h.manager.FindServiceFromLogin(loginName)
if login == nil {
http.Error(rw, "No login service defined for this username", http.StatusBadRequest)
return
}
// the @ must exist if the service is defined
n := strings.IndexByte(loginName, '@')
loginUn := loginName[:n]
now := time.Now()
future := now.AddDate(1, 0, 0)
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name",
Value: loginName,
Path: "/",
Expires: future,
MaxAge: int(future.Sub(now).Seconds()),
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// save state for use later
state := login.Config.Namespace + ":" + uuid.NewString()
h.flowState.Set(state, flowStateData{login}, time.Now().Add(15*time.Minute))
// generate oauth2 config and redirect to authorize URL
oa2conf := login.OAuth2Config
oa2conf.RedirectURL = h.conf.BaseUrl + "/callback"
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
http.Redirect(rw, req, nextUrl, http.StatusFound)
}
func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
flowState, ok := h.flowState.Get(req.FormValue("state"))
if !ok {
http.Error(rw, "Invalid flow state", http.StatusBadRequest)
return
}
token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", h.conf.BaseUrl+"/callback"))
if err != nil {
http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
return
}
sessionData := h.fetchUserInfo(rw, err, flowState.sso, token)
if sessionData.ID == "" {
http.Error(rw, "Failed to fetch user info", http.StatusInternalServerError)
return
}
if h.DbTx(rw, func(tx *database.Tx) error {
_, err := tx.GetUser(sessionData.ID)
if errors.Is(err, sql.ErrNoRows) {
uEmail := sessionData.UserInfo.GetStringOrDefault("email", "unknown@localhost")
uEmailVerified, _ := sessionData.UserInfo.GetBoolean("email_verified")
return tx.InsertUser(sessionData.ID, uEmail, uEmailVerified, "", true)
}
return err
}) {
return
}
// only continues if the above tx succeeds
auth.Data = sessionData
if auth.SaveSessionData() != nil {
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return
}
if h.setLoginDataCookie(rw, auth.Data.ID, token) {
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
return
}
h.SafeRedirect(rw, req)
}
func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, userId string, token *oauth2.Token) bool {
buf := new(bytes.Buffer)
buf.WriteString(userId)
buf.WriteByte(0)
err := json.NewEncoder(buf).Encode(token)
if err != nil {
return true
}
encryptedData, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, h.signingKey.PublicKey(), buf.Bytes(), []byte("lavender-login-data"))
if err != nil {
return true
}
encryptedString := base64.RawStdEncoding.EncodeToString(encryptedData)
http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-data",
Value: encryptedString,
Path: "/",
Expires: time.Now().AddDate(0, 3, 0),
Secure: true,
SameSite: http.SameSiteStrictMode,
})
return false
}
func (h *HttpServer) readLoginDataCookie(rw http.ResponseWriter, req *http.Request, u *UserAuth) {
loginCookie, err := req.Cookie("lavender-login-data")
if err != nil {
return
}
decryptedBytes, err := base64.RawStdEncoding.DecodeString(loginCookie.Value)
if err != nil {
return
}
decryptedData, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, h.signingKey.PrivateKey(), decryptedBytes, []byte("lavender-login-data"))
if err != nil {
return
}
buf := bytes.NewBuffer(decryptedData)
userId, err := buf.ReadString(0)
if err != nil {
return
}
userId = strings.TrimSuffix(userId, "\x00")
var token *oauth2.Token
err = json.NewDecoder(buf).Decode(&token)
if err != nil {
return
}
sso := h.manager.FindServiceFromLogin(userId)
if sso == nil {
return
}
u.Data = h.fetchUserInfo(rw, err, sso, token)
}
func (h *HttpServer) fetchUserInfo(rw http.ResponseWriter, err error, sso *issuer.WellKnownOIDC, token *oauth2.Token) SessionData {
res, err := sso.OAuth2Config.Client(context.Background(), token).Get(sso.UserInfoEndpoint)
if err != nil || res.StatusCode != http.StatusOK {
return SessionData{}
}
defer res.Body.Close()
var userInfoJson UserInfoFields
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return SessionData{}
}
subject, ok := userInfoJson.GetString("sub")
if !ok {
http.Error(rw, "Invalid subject", http.StatusInternalServerError)
return SessionData{}
}
subject += "@" + sso.Config.Namespace
displayName := userInfoJson.GetStringOrDefault("name", "Unknown Name")
return SessionData{
ID: subject,
DisplayName: displayName,
UserInfo: userInfoJson,
}
}