Skip to content
Snippets Groups Projects

Refactor the login handler

Merged ale requested to merge better-login into master
1 file
+ 26
9
Compare changes
  • Side-by-side
  • Inline
+ 415
0
package login
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"git.autistici.org/id/auth"
authclient "git.autistici.org/id/auth/client"
"github.com/tstranex/u2f"
"go.opencensus.io/trace"
"git.autistici.org/id/go-sso/server/device"
"git.autistici.org/id/go-sso/server/httputil"
)
const maxFailures = 5
type Auth struct {
// True if the user is authenticated.
Authenticated bool
// User name and other information (like group membership).
Username string
UserInfo *auth.UserInfo
// Services the user has logged in to from this session.
Services []string
}
// AddService adds a service to the current session (if it's not
// already there).
func (s *Auth) AddService(service string) {
for _, svc := range s.Services {
if svc == service {
return
}
}
s.Services = append(s.Services, service)
}
type loginSession struct {
Auth
// Temporary fields required by the login process.
Password string
AuthResponse *auth.Response
Redir string
Failures int
// Implementation detail of the session layer.
deleted bool
}
func (l *loginSession) Reset() {
l.Username = ""
l.UserInfo = nil
l.Services = nil
l.Authenticated = false
l.Password = ""
l.AuthResponse = nil
l.Failures = 0
// Keep Redir.
}
// This method is needlessly detailed, but the error message is useful in debugging.
//
// A boolean version could simply be:
//
// return (l.Username != "" && l.Password != "" && l.AuthResponse != nil &&
// l.AuthResponse.Has2FAMethod(method))
//
func (l *loginSession) Can2FA(method auth.TFAMethod) error {
switch {
case l.Username == "":
return errors.New("empty username")
case l.Password == "":
return errors.New("empty password")
case l.AuthResponse == nil:
return errors.New("empty auth response")
case !l.AuthResponse.Has2FAMethod(method):
return errors.New("unsupported 2fa method")
}
return nil
}
func (l *loginSession) Delete() {
l.deleted = true
}
type ctxKey int
const authCtxKey ctxKey = 0
func withAuth(ctx context.Context, s *Auth) context.Context {
return context.WithValue(ctx, authCtxKey, s)
}
// GetAuth returns the current user information, if any. Presence of an Auth
// object implies that the authentication succeeded.
func GetAuth(ctx context.Context) (*Auth, bool) {
s, ok := ctx.Value(authCtxKey).(*Auth)
return s, ok
}
// A LoginCallback will be invoked on every successful login, with username,
// password, and additional UserInfo. If it returns an error, the login
// workflow will fail.
type LoginCallback func(context.Context, string, string, *auth.UserInfo) error
// Login wraps an http.Handler with a login workflow.
type Login struct {
wrap http.Handler
sessionMgr *sessionManager
sessionTTL time.Duration
urlPrefix string
renderer *httputil.Renderer
authClient authclient.Client
authService string
u2fAppID string
devMgr *device.Manager
fallbackRedirect string
callback LoginCallback
}
// New returns a new Login wrapper.
func New(wrap http.Handler, devMgr *device.Manager, authClient authclient.Client, authService, u2fAppID, urlPrefix, fallbackRedirect string, renderer *httputil.Renderer, callback LoginCallback, keyPairs [][]byte, sessionTTL time.Duration) *Login {
if sessionTTL == 0 {
sessionTTL = 20 * time.Hour // default TTL.
}
smgr := newSessionManager(urlPrefix+"/", keyPairs[0], keyPairs[1], sessionTTL)
return &Login{
wrap: wrap,
sessionMgr: smgr,
sessionTTL: sessionTTL,
urlPrefix: urlPrefix,
renderer: renderer,
authClient: authClient,
authService: authService,
u2fAppID: u2fAppID,
devMgr: devMgr,
callback: callback,
fallbackRedirect: fallbackRedirect,
}
}
func (l *Login) urlFor(path string) string {
return l.urlPrefix + path
}
func (l *Login) fetchOrInitSession(req *http.Request) *loginSession {
session, err := l.sessionMgr.getSession(req)
if err != nil {
return new(loginSession)
}
return session
}
func (l *Login) ServeHTTP(w http.ResponseWriter, req *http.Request) {
session := l.fetchOrInitSession(req)
// Ensure that the session is saved.
w = l.sessionMgr.newSessionResponseWriter(w, req, session)
// A very simple router.
switch req.URL.Path {
case l.urlFor("/login"):
l.handleLogin(w, req, session)
case l.urlFor("/login/u2f"):
l.handleLoginU2F(w, req, session)
case l.urlFor("/login/otp"):
l.handleLoginOTP(w, req, session)
default:
// Wipe the session on logout, before passing through to the
// wrapped handler. Note that the Auth object will still
// contain valid data, but Authenticated will be set to false.
if req.URL.Path == l.urlFor("/logout") {
log.Printf("logging out user %s", session.Username)
session.Authenticated = false
session.Delete()
} else if !session.Authenticated {
// Save the current URL in the session for later redirect.
session.Redir = req.URL.String()
http.Redirect(w, req, "/login", http.StatusFound)
return
}
// Pass the AuthContext to the wrapped Handler via the
// request context.
req = req.WithContext(withAuth(req.Context(), &session.Auth))
l.wrap.ServeHTTP(w, req)
}
}
func (l *Login) loginOk(w http.ResponseWriter, req *http.Request, sess *loginSession, password string) {
if l.callback != nil {
if err := l.callback(req.Context(), sess.Username, password, sess.UserInfo); err != nil {
log.Printf("login callback error: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
target := sess.Redir
if target == "" {
// This is so if you navigate directly to the login URL, we
// try to send you somewhere meaningful anyway.
target = l.fallbackRedirect
}
sess.Authenticated = true
sess.Redir = ""
sess.Password = ""
sess.AuthResponse = nil
http.Redirect(w, req, target, http.StatusFound)
}
func (l *Login) handleLogin(w http.ResponseWriter, req *http.Request, sess *loginSession) {
if req.Method != "GET" && req.Method != "POST" {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// Reset the session to a known state.
sess.Reset()
// Case-fold usernames to lowercase.
username := strings.ToLower(req.FormValue("username"))
password := req.FormValue("password")
// If the request is a POST, attempt login with username/password.
env := map[string]interface{}{
"Error": false,
"Username": username,
}
if req.Method == "POST" && username != "" && password != "" {
resp, err := l.makeAuthRequest(w, req, username, password, "", nil)
if err != nil {
log.Printf("error in makeAuthRequest: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Save username / password and the AuthResponse for later in
// case of successful (or partially succesful) result.
sess.Username = username
switch resp.Status {
case auth.StatusOK:
l.loginOk(w, req, sess, password)
return
case auth.StatusInsufficientCredentials:
sess.Password = password
sess.AuthResponse = resp
// Always prefer U2F if supported, default to OTP. We
// are assuming that the auth.Response is well formed,
// and TFAMethods is not nil.
method := "otp"
if resp.Has2FAMethod(auth.TFAMethodU2F) {
method = "u2f"
}
http.Redirect(w, req, l.urlFor("/login/"+method), http.StatusFound)
return
}
env["Error"] = true
}
l.renderer.Render(w, req, "login_password.html", env)
}
func (l *Login) handleLoginOTP(w http.ResponseWriter, req *http.Request, sess *loginSession) {
if req.Method != "GET" && req.Method != "POST" {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// First verify that we are ready to do 2FA.
if err := sess.Can2FA(auth.TFAMethodOTP); err != nil {
log.Printf("got invalid 2FA request (%v)", err)
http.Redirect(w, req, l.urlFor("/login"), http.StatusFound)
return
}
otp := req.FormValue("otp")
env := map[string]interface{}{
"AuthResponse": sess.AuthResponse,
"Error": false,
}
if req.Method == "POST" && otp != "" {
resp, err := l.makeAuthRequest(w, req, sess.Username, sess.Password, otp, nil)
if err != nil {
log.Printf("error in makeAuthRequest: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if resp.Status == auth.StatusOK {
l.loginOk(w, req, sess, sess.Password)
return
}
env["Error"] = true
sess.Failures++
if sess.Failures >= maxFailures {
log.Printf("too many login failures for %s, starting over", sess.Username)
http.Redirect(w, req, l.urlFor("/login"), http.StatusFound)
return
}
}
l.renderer.Render(w, req, "login_otp.html", env)
}
func (l *Login) handleLoginU2F(w http.ResponseWriter, req *http.Request, sess *loginSession) {
if req.Method != "GET" && req.Method != "POST" {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
// First verify that we are ready to do 2FA.
if err := sess.Can2FA(auth.TFAMethodU2F); err != nil {
log.Printf("got invalid 2FA request (%v)", err)
http.Redirect(w, req, l.urlFor("/login"), http.StatusFound)
return
}
u2fresponse := req.FormValue("u2f_response")
env := map[string]interface{}{
"AuthResponse": sess.AuthResponse,
"U2FSignRequest": sess.AuthResponse.U2FSignRequest,
"Error": false,
}
if req.Method == "POST" && u2fresponse != "" {
var usr u2f.SignResponse
if err := json.Unmarshal([]byte(u2fresponse), &usr); err != nil {
log.Printf("error deserializing U2F SignResponse: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
resp, err := l.makeAuthRequest(w, req, sess.Username, sess.Password, "", &usr)
if err != nil {
log.Printf("error in makeAuthRequest: %v", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if resp.Status == auth.StatusOK {
l.loginOk(w, req, sess, sess.Password)
return
}
env["Error"] = true
sess.Failures++
if sess.Failures >= maxFailures {
log.Printf("too many login failures for %s, starting over", sess.Username)
http.Redirect(w, req, l.urlFor("/login"), http.StatusFound)
return
}
}
l.renderer.Render(w, req, "login_u2f.html", env)
}
// Make the auth request to the authentication server.
func (l *Login) makeAuthRequest(w http.ResponseWriter, req *http.Request, username, password, otp string, u2fResponse *u2f.SignResponse) (*auth.Response, error) {
appID := l.u2fAppID
if appID == "" {
appID = u2fAppIDFromRequest(req)
}
ar := auth.Request{
Service: l.authService,
Username: username,
Password: []byte(password),
OTP: otp,
DeviceInfo: l.devMgr.GetDeviceInfoFromRequest(w, req),
U2FResponse: u2fResponse,
U2FAppID: appID,
}
// Trace the authentication request.
ctx, span := trace.StartSpan(req.Context(), "auth",
trace.WithSpanKind(trace.SpanKindClient))
span.AddAttributes(
trace.StringAttribute("auth.user", username),
trace.StringAttribute("auth.service", l.authService),
trace.BoolAttribute("auth.with_password", len(password) > 0),
trace.BoolAttribute("auth.with_otp", otp != ""),
trace.BoolAttribute("auth.with_u2f", u2fResponse != nil),
)
defer span.End()
resp, err := l.authClient.Authenticate(ctx, &ar)
// Record the authentication response status in the trace.
if err != nil {
span.SetStatus(trace.Status{Code: trace.StatusCodeUnknown, Message: err.Error()})
} else if resp.Status == auth.StatusOK {
span.SetStatus(trace.Status{Code: trace.StatusCodeOK, Message: "OK"})
} else {
span.SetStatus(trace.Status{Code: trace.StatusCodePermissionDenied, Message: resp.Status.String()})
}
return resp, err
}
// Guess the correct U2F AppID from the HTTP request.
func u2fAppIDFromRequest(r *http.Request) string {
return fmt.Sprintf("https://%s", r.Host)
}
Loading