Implement client certificate login

This commit is contained in:
Jan Dittberner 2021-01-01 14:21:26 +01:00
parent 714a07f162
commit 7947eaf862
9 changed files with 307 additions and 119 deletions

View file

@ -1,3 +1,11 @@
[CertLoginIntroText]
hash = "sha1-3d611f594a2194a9b99f8b955e49877bd419a567"
other = "Du hast ein gültiges Client-Zertifikat für die folgenden E-Mail-Adressen vorgelegt:"
[CertLoginRequestText]
hash = "sha1-c1ad2600848ad6293ae6df6a04b0b2318bb303f9"
other = "Willst du dieses Zertifikat für die Anmeldung verwenden oder möchtest du lieber ein anderes Verfahren nutzen?"
[ErrorEmail] [ErrorEmail]
hash = "sha1-d2306dd8970ff616631a3501791297f31475e416" hash = "sha1-d2306dd8970ff616631a3501791297f31475e416"
other = "Bitte gib eine gültige E-Mailadresse ein." other = "Bitte gib eine gültige E-Mailadresse ein."
@ -48,6 +56,11 @@ other = "Auf der <a href=\"{{ .clientLink }}\">Beschreibungsseite</a> findest du
hash = "sha1-cb8efc74b5b726201321e0924747bf38d39629a1" hash = "sha1-cb8efc74b5b726201321e0924747bf38d39629a1"
other = "Die Anwendung <strong>{{ .client }}</strong> benötigt deine Einwilligungung für die angefragten Berechtigungen." other = "Die Anwendung <strong>{{ .client }}</strong> benötigt deine Einwilligungung für die angefragten Berechtigungen."
[LabelAcceptCertLogin]
description = "Label for a button to accept certificate login"
hash = "sha1-95cf27f4bdee62b51ee8bc673d25a46bcceed452"
other = "Ja, bitte nutze das Zertifikat"
[LabelConsent] [LabelConsent]
hash = "sha1-5e56a367cf99015bbe98488845541db00b7e04f6" hash = "sha1-5e56a367cf99015bbe98488845541db00b7e04f6"
other = "Ich erteile hiermit meine Einwilligung, dass die Anwendung die angefragten Berechtigungen erhalten darf." other = "Ich erteile hiermit meine Einwilligung, dass die Anwendung die angefragten Berechtigungen erhalten darf."
@ -57,6 +70,11 @@ description = "Label for a login button"
hash = "sha1-ff00822024fca849fe0cef21237b57218e706852" hash = "sha1-ff00822024fca849fe0cef21237b57218e706852"
other = "Anmelden" other = "Anmelden"
[LabelRejectCertLogin]
description = "Label for a button to reject certificate login"
hash = "sha1-911cc305bb66efe162641969aee6b88e5d28e24f"
other = "Nein, bitte frag nach meinem Passwort"
[LabelSubmit] [LabelSubmit]
hash = "sha1-2dacf65959849884a011f36f76a04eebea94c5ea" hash = "sha1-2dacf65959849884a011f36f76a04eebea94c5ea"
other = "Abschicken" other = "Abschicken"

View file

@ -1,3 +1,5 @@
CertLoginIntroText = "You have presented a valid client certificate for the following email addresses:"
CertLoginRequestText = "Do you want to use this certificate for authentication or do you want to use a different method?"
ErrorEmail = "Please enter a valid email address." ErrorEmail = "Please enter a valid email address."
ErrorEmailRequired = "Please enter an email address." ErrorEmailRequired = "Please enter an email address."
ErrorPasswordRequired = "Please enter a password." ErrorPasswordRequired = "Please enter a password."
@ -26,10 +28,18 @@ other = "Email:"
description = "Label for a password form field" description = "Label for a password form field"
other = "Password:" other = "Password:"
[LabelAcceptCertLogin]
description = "Label for a button to accept certificate login"
other = "Yes, please use the certificate"
[LabelLogin] [LabelLogin]
description = "Label for a login button" description = "Label for a login button"
other = "Login" other = "Login"
[LabelRejectCertLogin]
description = "Label for a button to reject certificate login"
other = "No, please ask for my password"
[LogoutLabel] [LogoutLabel]
description = "A label on a logout button or link" description = "A label on a logout button or link"
other = "Logout" other = "Logout"

View file

@ -3,8 +3,10 @@ package main
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"crypto/x509"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io/ioutil"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -52,6 +54,7 @@ func main() {
"server.name": "login.cacert.localhost", "server.name": "login.cacert.localhost",
"server.key": "certs/idp.cacert.localhost.key", "server.key": "certs/idp.cacert.localhost.key",
"server.certificate": "certs/idp.cacert.localhost.crt.pem", "server.certificate": "certs/idp.cacert.localhost.crt.pem",
"security.client.ca-file": "certs/client_ca.pem",
"admin.url": "https://hydra.cacert.localhost:4445/", "admin.url": "https://hydra.cacert.localhost:4445/",
"i18n.languages": []string{"en", "de"}, "i18n.languages": []string{"en", "de"},
}, "."), nil) }, "."), nil)
@ -133,16 +136,25 @@ func main() {
csrf.SameSite(csrf.SameSiteStrictMode), csrf.SameSite(csrf.SameSiteStrictMode),
csrf.MaxAge(600)) csrf.MaxAge(600))
clientCertPool := x509.NewCertPool()
pemBytes, err := ioutil.ReadFile(config.MustString("security.client.ca-file"))
if err != nil {
logger.Fatalf("could not load client CA certificates: %v", err)
}
clientCertPool.AppendCertsFromPEM(pemBytes)
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
ServerName: config.String("server.name"), ServerName: config.String("server.name"),
MinVersion: tls.VersionTLS12, MinVersion: tls.VersionTLS12,
ClientAuth: tls.VerifyClientCertIfGiven,
ClientCAs: clientCertPool,
} }
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port")), Addr: fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port")),
Handler: tracing(logging(hsts(csrfProtect(router)))), Handler: tracing(logging(hsts(csrfProtect(router)))),
ReadTimeout: 5 * time.Second, ReadTimeout: 20 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 20 * time.Second,
IdleTimeout: 15 * time.Second, IdleTimeout: 30 * time.Second,
TLSConfig: tlsConfig, TLSConfig: tlsConfig,
} }

View file

@ -99,7 +99,7 @@ func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.Context(), r.Context(),
`SELECT email, verified, fname, mname, lname, dob, language, modified `SELECT email, verified, fname, mname, lname, dob, language, modified
FROM users FROM users
WHERE id = ? WHERE uniqueID = ?
AND LOCKED = 0`, AND LOCKED = 0`,
) )
if err != nil { if err != nil {

View file

@ -13,6 +13,7 @@ import (
"github.com/go-playground/form/v4" "github.com/go-playground/form/v4"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"github.com/jmoiron/sqlx"
"github.com/nicksnyder/go-i18n/v2/i18n" "github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/ory/hydra-client-go/client/admin" "github.com/ory/hydra-client-go/client/admin"
"github.com/ory/hydra-client-go/models" "github.com/ory/hydra-client-go/models"
@ -22,27 +23,26 @@ import (
"git.cacert.org/oidc_login/idp/services" "git.cacert.org/oidc_login/idp/services"
) )
type acrType string
const (
ClientCertificate acrType = "cert" // client certificate login
Password acrType = "password" // regular username + password login
// ClientCertificateOTP acrType = "cert+otp"
// ClientCertificateToken acrType = "cert+token"
// PasswordOTP acrType = "password+otp"
// PasswordToken acrType = "password+token"
)
type loginHandler struct { type loginHandler struct {
adminClient *admin.Client adminClient *admin.Client
bundle *i18n.Bundle bundle *i18n.Bundle
context context.Context context context.Context
logger *log.Logger logger *log.Logger
loginTemplate *template.Template templates map[acrType]*template.Template
messageCatalog *commonServices.MessageCatalog messageCatalog *commonServices.MessageCatalog
} }
type acrType string
const (
NoCredentials acrType = "none"
ClientCertificate acrType = "cert"
ClientCertificateOTP acrType = "cert+otp"
ClientCertificateToken acrType = "cert+token"
Password acrType = "password"
PasswordOTP acrType = "password+otp"
PasswordToken acrType = "password+token"
)
type LoginInformation struct { type LoginInformation struct {
Email string `form:"email" validate:"required,email"` Email string `form:"email" validate:"required,email"`
Password string `form:"password" validate:"required"` Password string `form:"password" validate:"required"`
@ -55,18 +55,52 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
accept := r.Header.Get("Accept-Language") accept := r.Header.Get("Accept-Language")
localizer := i18n.NewLocalizer(h.bundle, accept) localizer := i18n.NewLocalizer(h.bundle, accept)
certEmails := h.getCertEmails(r)
var loginInfo LoginInformation
validate := validator.New() validate := validator.New()
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
if certEmails != nil {
h.renderRequestForClientCert(w, r, certEmails, localizer)
} else {
// render login form // render login form
h.renderLoginForm(w, r, map[string]string{}, &LoginInformation{}, localizer) h.renderLoginForm(w, r, map[string]string{}, &LoginInformation{}, localizer)
}
break break
case http.MethodPost: case http.MethodPost:
var loginInfo LoginInformation var userId *string
var authMethod acrType
// validate input if certEmails != nil && r.PostFormValue("action") == "cert-login" {
if r.PostFormValue("use-certificate") == "" {
// render login form
h.renderLoginForm(w, r, map[string]string{}, &LoginInformation{}, localizer)
return
}
// perform certificate auth
h.logger.Infof("would perform certificate authentication with: %+v", certEmails)
userId, err = h.performCertificateLogin(certEmails, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
if userId == nil {
errors := map[string]string{
"Form": h.messageCatalog.LookupMessage(
"WrongOrLockedUserOrInvalidPassword",
nil,
localizer,
),
}
h.renderLoginForm(w, r, errors, &loginInfo, localizer)
return
}
authMethod = ClientCertificate
} else {
decoder := form.NewDecoder() decoder := form.NewDecoder()
// validate input
err = decoder.Decode(&loginInfo, r.Form) err = decoder.Decode(&loginInfo, r.Form)
if err != nil { if err != nil {
h.logger.Error(err) h.logger.Error(err)
@ -78,38 +112,22 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
errors := make(map[string]string) errors := make(map[string]string)
for _, err := range err.(validator.ValidationErrors) { for _, err := range err.(validator.ValidationErrors) {
accept := r.Header.Get("Accept-Language") accept := r.Header.Get("Accept-Language")
errors[err.Field()] = h.messageCatalog.LookupErrorMessage(err.Tag(), err.Field(), err.Value(), i18n.NewLocalizer(h.bundle, accept)) errors[err.Field()] = h.messageCatalog.LookupErrorMessage(
err.Tag(),
err.Field(),
err.Value(),
i18n.NewLocalizer(h.bundle, accept),
)
} }
h.renderLoginForm(w, r, errors, &loginInfo, localizer) h.renderLoginForm(w, r, errors, &loginInfo, localizer)
return return
} }
userId, err = h.performUserNamePasswordLogin(&loginInfo, r)
db := services.GetDb(h.context)
stmt, err := db.PrepareContext(
r.Context(),
`SELECT id
FROM users
WHERE email = ?
AND password = ?
AND locked = 0`,
)
if err != nil { if err != nil {
h.logger.Errorf("error preparing login SQL: %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return return
} }
defer func() { _ = stmt.Close() }() if userId == nil {
// FIXME: replace with a real password hash algorithm
passwordHash := sha1.Sum([]byte(loginInfo.Password))
password := hex.EncodeToString(passwordHash[:])
// FIXME: introduce a real opaque identifier (i.e. a UUID)
var userId string
// GET user data
err = stmt.QueryRowContext(r.Context(), loginInfo.Email, password).Scan(&userId)
switch {
case err == sql.ErrNoRows:
errors := map[string]string{ errors := map[string]string{
"Form": h.messageCatalog.LookupMessage( "Form": h.messageCatalog.LookupMessage(
"WrongOrLockedUserOrInvalidPassword", "WrongOrLockedUserOrInvalidPassword",
@ -119,18 +137,17 @@ WHERE email = ?
} }
h.renderLoginForm(w, r, errors, &loginInfo, localizer) h.renderLoginForm(w, r, errors, &loginInfo, localizer)
return return
case err != nil: }
h.logger.Errorf("error performing login SQL: %v", err) authMethod = Password
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) }
return
default:
// finish login and redirect to target // finish login and redirect to target
loginRequest, err := h.adminClient.AcceptLoginRequest( loginRequest, err := h.adminClient.AcceptLoginRequest(
admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(&models.AcceptLoginRequest{ admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(&models.AcceptLoginRequest{
Acr: string(Password), Acr: string(authMethod),
Remember: true, Remember: true,
RememberFor: 0, RememberFor: 0,
Subject: &userId, Subject: userId,
}).WithTimeout(time.Second * 10)) }).WithTimeout(time.Second * 10))
if err != nil { if err != nil {
h.logger.Errorf("error getting login request: %#v", err) h.logger.Errorf("error getting login request: %#v", err)
@ -139,20 +156,29 @@ WHERE email = ?
} }
w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo) w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo)
w.WriteHeader(http.StatusFound) w.WriteHeader(http.StatusFound)
}
break
default: default:
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
return return
} }
} }
func (h *loginHandler) getCertEmails(r *http.Request) []string {
if r.TLS != nil && r.TLS.PeerCertificates != nil && len(r.TLS.PeerCertificates) > 0 {
firstCert := r.TLS.PeerCertificates[0]
for _, email := range firstCert.EmailAddresses {
h.logger.Infof("authenticated with a client certificate for email address %s", email)
}
return firstCert.EmailAddresses
}
return nil
}
func (h *loginHandler) renderLoginForm(w http.ResponseWriter, r *http.Request, errors map[string]string, info *LoginInformation, localizer *i18n.Localizer) { func (h *loginHandler) renderLoginForm(w http.ResponseWriter, r *http.Request, errors map[string]string, info *LoginInformation, localizer *i18n.Localizer) {
trans := func(label string) string { trans := func(label string) string {
return h.messageCatalog.LookupMessage(label, nil, localizer) return h.messageCatalog.LookupMessage(label, nil, localizer)
} }
err := h.loginTemplate.Lookup("base").Execute(w, map[string]interface{}{ err := h.templates[Password].Lookup("base").Execute(w, map[string]interface{}{
"Title": trans("LoginTitle"), "Title": trans("LoginTitle"),
csrf.TemplateTag: csrf.TemplateField(r), csrf.TemplateTag: csrf.TemplateField(r),
"LabelEmail": trans("LabelEmail"), "LabelEmail": trans("LabelEmail"),
@ -168,18 +194,121 @@ func (h *loginHandler) renderLoginForm(w http.ResponseWriter, r *http.Request, e
} }
} }
func (h *loginHandler) renderRequestForClientCert(w http.ResponseWriter, r *http.Request, emails []string, localizer *i18n.Localizer) {
trans := func(label string) string {
return h.messageCatalog.LookupMessage(label, nil, localizer)
}
err := h.templates[ClientCertificate].Lookup("base").Execute(w, map[string]interface{}{
"Title": trans("LoginTitle"),
csrf.TemplateTag: csrf.TemplateField(r),
"IntroText": trans("CertLoginIntroText"),
"emails": emails,
"RequestText": trans("CertLoginRequestText"),
"AcceptLabel": trans("LabelAcceptCertLogin"),
"RejectLabel": trans("LabelRejectCertLogin"),
})
if err != nil {
h.logger.Error(err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
}
func (h *loginHandler) performUserNamePasswordLogin(loginInfo *LoginInformation, r *http.Request) (*string, error) {
db := services.GetDb(h.context)
stmt, err := db.PrepareContext(
r.Context(),
`SELECT uniqueID
FROM users
WHERE email = ?
AND password = ?
AND locked = 0`,
)
if err != nil {
h.logger.Errorf("error preparing login SQL: %v", err)
return nil, err
}
defer func() { _ = stmt.Close() }()
// FIXME: replace with a real password hash algorithm
passwordHash := sha1.Sum([]byte(loginInfo.Password))
password := hex.EncodeToString(passwordHash[:])
var userId string
// GET user data
err = stmt.QueryRowContext(r.Context(), loginInfo.Email, password).Scan(&userId)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
h.logger.Errorf("error performing login SQL: %v", err)
return nil, err
default:
h.logger.Infof("found user %s", userId)
return &userId, nil
}
}
func (h *loginHandler) performCertificateLogin(emails []string, r *http.Request) (*string, error) {
db := services.GetDb(h.context)
query, args, err := sqlx.In(
`SELECT DISTINCT u.uniqueID
FROM users u
JOIN email e ON e.memid = u.id
WHERE e.email IN (?)
AND u.locked = 0`,
emails,
)
if err != nil {
h.logger.Errorf("could not parse IN query for certificate login: %v", err)
return nil, err
}
stmt, err := db.PreparexContext(r.Context(), query)
if err != nil {
h.logger.Errorf("error preparing login SQL: %v", err)
return nil, err
}
defer func() { _ = stmt.Close() }()
var userId string
err = stmt.QueryRowContext(r.Context(), args...).Scan(&userId)
switch {
case err == sql.ErrNoRows:
return nil, nil
case err != nil:
h.logger.Errorf("error performing login SQL: %v", err)
return nil, err
default:
h.logger.Infof("found user %s", userId)
return &userId, nil
}
}
func NewLoginHandler(ctx context.Context, logger *log.Logger) (*loginHandler, error) { func NewLoginHandler(ctx context.Context, logger *log.Logger) (*loginHandler, error) {
var err error
loginTemplate, err := template.ParseFiles( loginTemplate, err := template.ParseFiles(
"templates/idp/base.gohtml", "templates/idp/login.gohtml") "templates/idp/base.gohtml", "templates/idp/login.gohtml")
if err != nil { if err != nil {
return nil, err return nil, err
} }
clientCertTemplate, err := template.ParseFiles(
"templates/idp/base.gohtml", "templates/idp/client_certificate.gohtml")
if err != nil {
return nil, err
}
formTemplates := map[acrType]*template.Template{
Password: loginTemplate,
ClientCertificate: clientCertTemplate,
}
return &loginHandler{ return &loginHandler{
adminClient: ctx.Value(CtxAdminClient).(*admin.Client), adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
bundle: commonServices.GetI18nBundle(ctx), bundle: commonServices.GetI18nBundle(ctx),
context: ctx, context: ctx,
logger: logger, logger: logger,
loginTemplate: loginTemplate, templates: formTemplates,
messageCatalog: commonServices.GetMessageCatalog(ctx), messageCatalog: commonServices.GetMessageCatalog(ctx),
}, nil }, nil
} }

View file

@ -89,5 +89,23 @@ func AddMessages(ctx context.Context) {
Description: "Label for a login button", Description: "Label for a login button",
Other: "Login", Other: "Login",
} }
messages["CertLoginIntroText"] = &i18n.Message{
ID: "CertLoginIntroText",
Other: "You have presented a valid client certificate for the following email addresses:",
}
messages["CertLoginRequestText"] = &i18n.Message{
ID: "CertLoginRequestText",
Other: "Do you want to use this certificate for authentication or do you want to use a different method?",
}
messages["LabelAcceptCertLogin"] = &i18n.Message{
ID: "LabelAcceptCertLogin",
Description: "Label for a button to accept certificate login",
Other: "Yes, please use the certificate",
}
messages["LabelRejectCertLogin"] = &i18n.Message{
ID: "LabelRejectCertLogin",
Description: "Label for a button to reject certificate login",
Other: "No, please ask for my password",
}
services.GetMessageCatalog(ctx).AddMessages(messages) services.GetMessageCatalog(ctx).AddMessages(messages)
} }

View file

@ -0,0 +1,14 @@
{{ define "content" }}
<p>{{ .IntroText }}</p>
<ul>
{{ range .emails }}
<li>{{ . }}</li>{{ end }}
</ul>
<p>{{ .RequestText }}</p>
<form method="post">
{{ .csrfField }}
<input type="hidden" name="action" value="cert-login"/>
<button type="submit" name="use-certificate" value="yes">{{ .AcceptLabel }}</button>
<button type="submit">{{ .RejectLabel }}</button>
</form>
{{ end }}

View file

@ -1,6 +1,7 @@
{{ define "content" }} {{ define "content" }}
<form method="post"> <form method="post">
{{ .csrfField }} {{ .csrfField }}
<input type="hidden" name="action" value="password-login"/>
{{ if .errors.Form}}<p>{{ .errors.Form }}</p>{{ end }} {{ if .errors.Form}}<p>{{ .errors.Form }}</p>{{ end }}
{{ if .errors.Email }}<p>{{ .errors.Email }}</p>{{ end }} {{ if .errors.Email }}<p>{{ .errors.Email }}</p>{{ end }}
<label for="email">{{ .LabelEmail }}</label> <label for="email">{{ .LabelEmail }}</label>

View file

@ -1,18 +1,4 @@
[FormLabelEmail] [LabelAcceptCertLogin]
description = "Label for an email form field" description = "Label for a button to accept certificate login"
hash = "sha1-ce1aa6771caccb8c901c6627e7ab5c554e9944da" hash = "sha1-95cf27f4bdee62b51ee8bc673d25a46bcceed452"
other = "E-Mail:" other = "Ja, bitte nutze das Zertifikat"
[FormLabelPassword]
description = "Label for a password form field"
hash = "sha1-a3c9deb12ea191bb380ea7ad076417b1fff14f28"
other = "Passwort:"
[LabelLogin]
description = "Label for a login button"
hash = "sha1-ff00822024fca849fe0cef21237b57218e706852"
other = "Anmelden"
[LoginTitle]
hash = "sha1-4e5a2893bdcc7d239c1db72e4c4ffbe4bea73174"
other = "Anmeldung"