413 lines
10 KiB
Go
413 lines
10 KiB
Go
// SPDX-License-Identifier: MIT
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/gob"
|
|
"fmt"
|
|
"html/template"
|
|
"image/color"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
kitlog "github.com/go-kit/kit/log"
|
|
"github.com/go-kit/kit/log/level"
|
|
"github.com/gorilla/mux"
|
|
"github.com/gorilla/sessions"
|
|
"github.com/skip2/go-qrcode"
|
|
"go.cryptoscope.co/muxrpc/v2"
|
|
"go.mindeco.de/http/render"
|
|
"go.mindeco.de/logging"
|
|
|
|
"github.com/ssb-ngi-pointer/go-ssb-room/internal/network"
|
|
"github.com/ssb-ngi-pointer/go-ssb-room/internal/signinwithssb"
|
|
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
|
|
weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors"
|
|
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
|
|
refs "go.mindeco.de/ssb-refs"
|
|
)
|
|
|
|
// WithSSBHandler implements the oauth-like challenge/response dance described in
|
|
// https://ssb-ngi-pointer.github.io/rooms2/#sign-in-with-ssb
|
|
type WithSSBHandler struct {
|
|
roomID refs.FeedRef
|
|
|
|
membersdb roomdb.MembersService
|
|
aliasesdb roomdb.AliasesService
|
|
sessiondb roomdb.AuthWithSSBService
|
|
|
|
cookieStore sessions.Store
|
|
|
|
endpoints network.Endpoints
|
|
|
|
bridge *signinwithssb.SignalBridge
|
|
}
|
|
|
|
type registerToEventSourceMap map[string]<-chan signinwithssb.Event
|
|
|
|
func NewWithSSBHandler(
|
|
m *mux.Router,
|
|
r *render.Renderer,
|
|
roomID refs.FeedRef,
|
|
endpoints network.Endpoints,
|
|
aliasDB roomdb.AliasesService,
|
|
membersDB roomdb.MembersService,
|
|
sessiondb roomdb.AuthWithSSBService,
|
|
cookies sessions.Store,
|
|
bridge *signinwithssb.SignalBridge,
|
|
) *WithSSBHandler {
|
|
|
|
var ssb WithSSBHandler
|
|
ssb.roomID = roomID
|
|
ssb.aliasesdb = aliasDB
|
|
ssb.membersdb = membersDB
|
|
ssb.endpoints = endpoints
|
|
ssb.sessiondb = sessiondb
|
|
ssb.cookieStore = cookies
|
|
ssb.bridge = bridge
|
|
|
|
m.Get(router.AuthWithSSBSignIn).HandlerFunc(r.HTML("auth/withssb_sign_in.tmpl", ssb.login))
|
|
|
|
m.HandleFunc("/sse/login", r.HTML("auth/withssb_server_start.tmpl", ssb.startWithServer))
|
|
m.HandleFunc("/sse/events", ssb.eventSource)
|
|
|
|
return &ssb
|
|
}
|
|
|
|
func (h WithSSBHandler) login(w http.ResponseWriter, req *http.Request) (interface{}, error) {
|
|
queryParams := req.URL.Query()
|
|
|
|
var clientReq signinwithssb.ClientRequest
|
|
clientReq.ServerID = h.roomID // fill in the server
|
|
|
|
// validate and update client challenge
|
|
cc := queryParams.Get("cc")
|
|
clientReq.ClientChallenge = cc
|
|
|
|
// check who the client is
|
|
var client refs.FeedRef
|
|
if cid := queryParams.Get("cid"); cid != "" {
|
|
parsed, err := refs.ParseFeedRef(cid)
|
|
if err != nil {
|
|
return nil, weberrors.ErrBadRequest{Where: "cid", Details: err}
|
|
}
|
|
client = *parsed
|
|
} else {
|
|
alias, err := h.aliasesdb.Resolve(req.Context(), queryParams.Get("alias"))
|
|
if err != nil {
|
|
return nil, weberrors.ErrBadRequest{Where: "alias", Details: err}
|
|
}
|
|
client = alias.Feed
|
|
}
|
|
|
|
// check that we have that member
|
|
member, err := h.membersdb.GetByFeed(req.Context(), client)
|
|
if err != nil {
|
|
errMsg := fmt.Errorf("sign-in with ssb: client isnt a member: %w", err)
|
|
if err == roomdb.ErrNotFound {
|
|
return nil, weberrors.ErrForbidden{Details: errMsg}
|
|
}
|
|
return nil, errMsg
|
|
}
|
|
clientReq.ClientID = client
|
|
|
|
// get the connected client for that member
|
|
edp, connected := h.endpoints.GetEndpointFor(client)
|
|
if !connected {
|
|
return nil, weberrors.ErrForbidden{Details: fmt.Errorf("sign-in: client not connected to room")}
|
|
}
|
|
|
|
// roll a Challenge from the server
|
|
sc := signinwithssb.GenerateChallenge()
|
|
clientReq.ServerChallenge = sc
|
|
|
|
ctx, cancel := context.WithTimeout(req.Context(), 1*time.Minute)
|
|
defer cancel()
|
|
|
|
// request the signed solution over muxrpc
|
|
var solution string
|
|
err = edp.Async(ctx, &solution, muxrpc.TypeString, muxrpc.Method{"httpAuth", "requestSolution"}, sc, cc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sign-in with ssb: could not request solution from client: %w", err)
|
|
}
|
|
|
|
// decode and validate the response
|
|
solution = strings.TrimSuffix(solution, ".sig.ed25519")
|
|
solutionBytes, err := base64.StdEncoding.DecodeString(solution)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("sign-in with ssb: failed to decode solution: %w", err)
|
|
}
|
|
|
|
if !clientReq.Validate(solutionBytes) {
|
|
return nil, fmt.Errorf("sign-in with ssb: validation of client solution failed")
|
|
}
|
|
|
|
// create a session for invalidation
|
|
tok, err := h.sessiondb.CreateToken(req.Context(), member.ID)
|
|
if err != nil {
|
|
err = fmt.Errorf("sign-in with ssb: could not create token: %w", err)
|
|
return nil, err
|
|
}
|
|
|
|
session, err := h.cookieStore.Get(req, siwssbSessionName)
|
|
if err != nil {
|
|
err = fmt.Errorf("sign-in with ssb: failed to load cookie session: %w", err)
|
|
return nil, err
|
|
}
|
|
|
|
session.Values[memberToken] = tok
|
|
session.Values[userTimeout] = time.Now().Add(lifetime)
|
|
if err := session.Save(req, w); err != nil {
|
|
err = fmt.Errorf("sign-in with ssb: failed to update cookie session: %w", err)
|
|
return nil, err
|
|
}
|
|
|
|
return "you are now logged in!", nil
|
|
}
|
|
|
|
// custom sessionKey type to prevent collision
|
|
type sessionKey uint
|
|
|
|
func init() {
|
|
// need to register our Key with gob so gorilla/sessions can (de)serialize it
|
|
gob.Register(memberToken)
|
|
gob.Register(time.Time{})
|
|
}
|
|
|
|
const (
|
|
siwssbSessionName = "AuthWithSSBSession"
|
|
|
|
memberToken sessionKey = iota
|
|
userTimeout
|
|
)
|
|
|
|
const lifetime = time.Hour * 24
|
|
|
|
// AuthenticateRequest uses the passed request to load and return the session data that was stored previously.
|
|
// If it is invalid or there is no session, it will return ErrNotAuthorized.
|
|
// Otherwise it will return the member ID that belongs to the session.
|
|
func (h WithSSBHandler) AuthenticateRequest(r *http.Request) (*roomdb.Member, error) {
|
|
session, err := h.cookieStore.Get(r, siwssbSessionName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if session.IsNew {
|
|
return nil, weberrors.ErrNotAuthorized
|
|
}
|
|
|
|
tokenVal, ok := session.Values[memberToken]
|
|
if !ok {
|
|
return nil, weberrors.ErrNotAuthorized
|
|
}
|
|
|
|
t, ok := session.Values[userTimeout]
|
|
if !ok {
|
|
return nil, weberrors.ErrNotAuthorized
|
|
}
|
|
|
|
tout, ok := t.(time.Time)
|
|
if !ok {
|
|
return nil, weberrors.ErrNotAuthorized
|
|
}
|
|
|
|
if time.Now().After(tout) {
|
|
return nil, weberrors.ErrNotAuthorized
|
|
}
|
|
|
|
token, ok := tokenVal.(string)
|
|
if !ok {
|
|
return nil, weberrors.ErrNotAuthorized
|
|
}
|
|
|
|
memberID, err := h.sessiondb.CheckToken(r.Context(), token)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
member, err := h.membersdb.GetByID(r.Context(), memberID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &member, nil
|
|
}
|
|
|
|
// Logout destroys the session data and updates the cookie with an invalidated one.
|
|
func (h WithSSBHandler) Logout(w http.ResponseWriter, r *http.Request) error {
|
|
session, err := h.cookieStore.Get(r, siwssbSessionName)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
tokenVal, ok := session.Values[memberToken]
|
|
if !ok {
|
|
// not a sign-in with ssb session
|
|
return nil
|
|
}
|
|
|
|
token, ok := tokenVal.(string)
|
|
if !ok {
|
|
return fmt.Errorf("wrong token type: %T", tokenVal)
|
|
}
|
|
|
|
err = h.sessiondb.RemoveToken(r.Context(), token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
session.Values[userTimeout] = time.Now().Add(-lifetime)
|
|
session.Options.MaxAge = -1
|
|
if err := session.Save(r, w); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// server-sent-events stuff
|
|
|
|
func (h WithSSBHandler) startWithServer(w http.ResponseWriter, req *http.Request) (interface{}, error) {
|
|
sc := h.bridge.RegisterSession()
|
|
|
|
var queryParams = make(url.Values)
|
|
queryParams.Set("action", "start-http-auth")
|
|
queryParams.Set("sid", h.roomID.Ref())
|
|
queryParams.Set("sc", sc)
|
|
|
|
var startAuthURI url.URL
|
|
startAuthURI.Scheme = "ssb"
|
|
startAuthURI.Opaque = "experimental"
|
|
startAuthURI.RawQuery = queryParams.Encode()
|
|
|
|
// generate a QR code with the token inside so that you can open it easily in a supporting mobile app
|
|
qrCode, err := qrcode.New(startAuthURI.String(), qrcode.Medium)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
qrCode.BackgroundColor = color.RGBA{R: 0xf9, G: 0xfa, B: 0xfb}
|
|
qrCode.ForegroundColor = color.Black
|
|
|
|
qrCodeData, err := qrCode.PNG(-5)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
qrURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(qrCodeData)
|
|
|
|
return struct {
|
|
SSBURI template.URL
|
|
QRCodeURI template.URL
|
|
ServerChallenge string
|
|
}{template.URL(startAuthURI.String()), template.URL(qrURI), sc}, nil
|
|
}
|
|
|
|
type event struct {
|
|
ID uint32
|
|
Data string
|
|
Event string
|
|
}
|
|
|
|
func (h WithSSBHandler) eventSource(w http.ResponseWriter, r *http.Request) {
|
|
flusher, err := w.(http.Flusher)
|
|
if !err {
|
|
http.Error(w, "Streaming unsupported!", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
notifier, ok := w.(http.CloseNotifier)
|
|
if !ok {
|
|
http.Error(w, "cant notify about closed requests", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// setup server-sent events
|
|
// https://html.spec.whatwg.org/multipage/server-sent-events.html
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("Transfer-Encoding", "chunked")
|
|
|
|
// Get the StreamID from the URL
|
|
sc := r.URL.Query().Get("sc")
|
|
if sc == "" {
|
|
http.Error(w, "Please specify a stream!", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
logger := logging.FromContext(r.Context())
|
|
logger = level.Debug(logger)
|
|
logger = kitlog.With(logger, "stream", sc)
|
|
logger.Log("event", "stream opened")
|
|
|
|
evtCh, has := h.bridge.GetEventChannel(sc)
|
|
if !has {
|
|
http.Error(w, "No such session!", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
tick := time.NewTicker(3 * time.Second)
|
|
go func() {
|
|
time.Sleep(3 * time.Minute)
|
|
tick.Stop()
|
|
logger.Log("event", "stopped")
|
|
}()
|
|
|
|
start := time.Now()
|
|
flusher.Flush()
|
|
|
|
// Push events to client
|
|
var (
|
|
evtID = uint32(0)
|
|
)
|
|
|
|
notify := notifier.CloseNotify()
|
|
for {
|
|
select {
|
|
|
|
case <-notify:
|
|
logger.Log("event", "request closed")
|
|
return
|
|
|
|
case <-tick.C:
|
|
msg := fmt.Sprintf("Waiting for solution (session age: %s)", time.Since(start))
|
|
sendServerEvent(w, event{
|
|
ID: evtID,
|
|
Data: msg,
|
|
Event: "ping",
|
|
})
|
|
logger.Log("event", "sent", "ping", evtID)
|
|
|
|
case update := <-evtCh:
|
|
evt := event{
|
|
ID: evtID,
|
|
Data: "challenge validation failed",
|
|
Event: "failed",
|
|
}
|
|
|
|
if update.Worked {
|
|
evt.Event = "success"
|
|
evt.Data = update.Token
|
|
}
|
|
|
|
sendServerEvent(w, evt)
|
|
|
|
logger.Log("event", "sent", "worked", update.Worked)
|
|
}
|
|
evtID++
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
func sendServerEvent(w io.Writer, evt event) {
|
|
fmt.Fprintf(w, "id: %d\n", evt.ID)
|
|
fmt.Fprintf(w, "data: %s\n", evt.Data)
|
|
if len(evt.Event) > 0 {
|
|
fmt.Fprintf(w, "event: %s\n", evt.Event)
|
|
}
|
|
fmt.Fprint(w, "\n")
|
|
}
|