252 lines
8.2 KiB
Go
252 lines
8.2 KiB
Go
|
/*
|
||
|
Copyright 2020, 2021 Jan Dittberner
|
||
|
|
||
|
|
||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||
|
you may not use this file except in compliance with the License.
|
||
|
You may obtain a copy of the License at
|
||
|
|
||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||
|
|
||
|
Unless required by applicable law or agreed to in writing, software
|
||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
|
See the License for the specific language governing permissions and
|
||
|
limitations under the License.
|
||
|
*/
|
||
|
|
||
|
package handlers
|
||
|
|
||
|
import (
|
||
|
"bytes"
|
||
|
"context"
|
||
|
"errors"
|
||
|
"fmt"
|
||
|
"html/template"
|
||
|
"net/http"
|
||
|
"strconv"
|
||
|
"time"
|
||
|
|
||
|
"github.com/gorilla/csrf"
|
||
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
||
|
"github.com/ory/hydra-client-go/client/admin"
|
||
|
"github.com/ory/hydra-client-go/models"
|
||
|
log "github.com/sirupsen/logrus"
|
||
|
|
||
|
"git.cacert.org/oidc_idp/services"
|
||
|
)
|
||
|
|
||
|
type acrType string
|
||
|
|
||
|
const (
|
||
|
ClientCertificate acrType = "cert" // client certificate login
|
||
|
// ClientCertificateOTP acrType = "cert+otp"
|
||
|
// ClientCertificateToken acrType = "cert+token"
|
||
|
)
|
||
|
|
||
|
type templateName string
|
||
|
|
||
|
const (
|
||
|
CertificateLogin templateName = "cert"
|
||
|
NoEmailsInClientCertificate = "no_emails"
|
||
|
)
|
||
|
|
||
|
type loginHandler struct {
|
||
|
adminClient *admin.Client
|
||
|
bundle *i18n.Bundle
|
||
|
context context.Context
|
||
|
logger *log.Logger
|
||
|
templates map[templateName]*template.Template
|
||
|
messageCatalog *services.MessageCatalog
|
||
|
}
|
||
|
|
||
|
func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||
|
var err error
|
||
|
challenge := r.URL.Query().Get("login_challenge")
|
||
|
h.logger.Debugf("received login challenge %s\n", challenge)
|
||
|
accept := r.Header.Get("Accept-Language")
|
||
|
localizer := i18n.NewLocalizer(h.bundle, accept)
|
||
|
|
||
|
certEmails := h.getEmailAddressesFromClientCertificate(r)
|
||
|
|
||
|
if certEmails == nil {
|
||
|
h.renderNoEmailsInClientCertificate(w, localizer)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
switch r.Method {
|
||
|
case http.MethodGet:
|
||
|
loginRequest, err := h.adminClient.GetLoginRequest(admin.NewGetLoginRequestParams().WithLoginChallenge(challenge))
|
||
|
if err != nil {
|
||
|
h.logger.Warnf("could not get login request for challenge %s: %v", challenge, err)
|
||
|
|
||
|
var e *admin.GetLoginRequestGone
|
||
|
if errors.As(err, &e) {
|
||
|
w.Header().Set("Location", *e.GetPayload().RedirectTo)
|
||
|
w.WriteHeader(http.StatusGone)
|
||
|
return
|
||
|
}
|
||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||
|
return
|
||
|
}
|
||
|
h.renderRequestForClientCert(w, r, certEmails, localizer, loginRequest)
|
||
|
break
|
||
|
case http.MethodPost:
|
||
|
if r.FormValue("use-identity") != "accept" {
|
||
|
h.rejectLogin(w, challenge, localizer)
|
||
|
return
|
||
|
}
|
||
|
|
||
|
var userId string
|
||
|
// 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
|
||
|
}
|
||
|
|
||
|
// finish login and redirect to target
|
||
|
loginRequest, err := h.adminClient.AcceptLoginRequest(
|
||
|
admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(
|
||
|
&models.AcceptLoginRequest{
|
||
|
Acr: string(ClientCertificate),
|
||
|
Remember: true,
|
||
|
RememberFor: 0,
|
||
|
Subject: &userId,
|
||
|
}).WithTimeout(time.Second * 10))
|
||
|
if err != nil {
|
||
|
h.logger.Errorf("error getting login request: %#v", err)
|
||
|
var errorDetails *ErrorDetails
|
||
|
switch v := err.(type) {
|
||
|
case *admin.AcceptLoginRequestNotFound:
|
||
|
payload := v.GetPayload()
|
||
|
errorDetails = &ErrorDetails{
|
||
|
ErrorMessage: payload.Error,
|
||
|
ErrorDetails: []string{payload.ErrorDescription},
|
||
|
}
|
||
|
if v.Payload.StatusCode != 0 {
|
||
|
errorDetails.ErrorCode = strconv.Itoa(int(payload.StatusCode))
|
||
|
}
|
||
|
break
|
||
|
default:
|
||
|
errorDetails = &ErrorDetails{
|
||
|
ErrorMessage: "could not accept login",
|
||
|
ErrorDetails: []string{err.Error()},
|
||
|
}
|
||
|
}
|
||
|
GetErrorBucket(r).AddError(errorDetails)
|
||
|
return
|
||
|
}
|
||
|
w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo)
|
||
|
w.WriteHeader(http.StatusFound)
|
||
|
default:
|
||
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func (h *loginHandler) rejectLogin(w http.ResponseWriter, challenge string, localizer *i18n.Localizer) {
|
||
|
rejectLoginRequest, err := h.adminClient.RejectLoginRequest(admin.NewRejectLoginRequestParams().WithLoginChallenge(challenge).WithBody(
|
||
|
&models.RejectRequest{
|
||
|
ErrorDescription: h.messageCatalog.LookupMessage("LoginDeniedByUser", nil, localizer),
|
||
|
ErrorHint: h.messageCatalog.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer),
|
||
|
StatusCode: http.StatusForbidden,
|
||
|
},
|
||
|
).WithTimeout(time.Second * 10))
|
||
|
if err != nil {
|
||
|
h.logger.Errorf("error getting reject login request: %#v", err)
|
||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||
|
return
|
||
|
}
|
||
|
w.Header().Set("Location", *rejectLoginRequest.GetPayload().RedirectTo)
|
||
|
w.WriteHeader(http.StatusFound)
|
||
|
}
|
||
|
|
||
|
func (h *loginHandler) getEmailAddressesFromClientCertificate(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) renderRequestForClientCert(w http.ResponseWriter, r *http.Request, emails []string, localizer *i18n.Localizer, loginRequest *admin.GetLoginRequestOK) {
|
||
|
trans := func(label string) string {
|
||
|
return h.messageCatalog.LookupMessage(label, nil, localizer)
|
||
|
}
|
||
|
|
||
|
rendered := bytes.NewBuffer(make([]byte, 0))
|
||
|
err := h.templates[CertificateLogin].Lookup("base").Execute(rendered, map[string]interface{}{
|
||
|
"Title": trans("LoginTitle"),
|
||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||
|
"IntroText": template.HTML(h.messageCatalog.LookupMessage(
|
||
|
"CertLoginIntroText",
|
||
|
map[string]interface{}{"ClientName": loginRequest.GetPayload().Client.ClientName},
|
||
|
localizer,
|
||
|
)),
|
||
|
"EmailChoiceText": h.messageCatalog.LookupMessagePlural("EmailChoiceText", nil, localizer, len(emails)),
|
||
|
"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
|
||
|
}
|
||
|
w.Header().Add("Pragma", "no-cache")
|
||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||
|
_, _ = w.Write(rendered.Bytes())
|
||
|
}
|
||
|
|
||
|
func (h *loginHandler) performCertificateLogin(emails []string, r *http.Request) (string, error) {
|
||
|
requestedEmail := r.PostFormValue("email")
|
||
|
for _, email := range emails {
|
||
|
if email == requestedEmail {
|
||
|
return email, nil
|
||
|
}
|
||
|
}
|
||
|
return "", fmt.Errorf("no user found")
|
||
|
}
|
||
|
|
||
|
func (h *loginHandler) renderNoEmailsInClientCertificate(w http.ResponseWriter, localizer *i18n.Localizer) {
|
||
|
trans := func(label string) string {
|
||
|
return h.messageCatalog.LookupMessage(label, nil, localizer)
|
||
|
}
|
||
|
|
||
|
err := h.templates[NoEmailsInClientCertificate].Lookup("base").Execute(w, map[string]interface{}{
|
||
|
"Title": trans("NoEmailsInClientCertificateTitle"),
|
||
|
"Explanation": trans("NoEmailsInClientCertificateExplanation"),
|
||
|
})
|
||
|
if err != nil {
|
||
|
h.logger.Error(err)
|
||
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||
|
return
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func NewLoginHandler(ctx context.Context, logger *log.Logger) (*loginHandler, error) {
|
||
|
return &loginHandler{
|
||
|
adminClient: ctx.Value(CtxAdminClient).(*admin.Client),
|
||
|
bundle: services.GetI18nBundle(ctx),
|
||
|
context: ctx,
|
||
|
logger: logger,
|
||
|
templates: map[templateName]*template.Template{
|
||
|
CertificateLogin: template.Must(template.ParseFiles(
|
||
|
"templates/base.gohtml",
|
||
|
"templates/client_certificate.gohtml",
|
||
|
)),
|
||
|
NoEmailsInClientCertificate: template.Must(template.ParseFiles(
|
||
|
"templates/base.gohtml",
|
||
|
"templates/no_email_in_client_certificate.gohtml",
|
||
|
)),
|
||
|
},
|
||
|
messageCatalog: services.GetMessageCatalog(ctx),
|
||
|
}, nil
|
||
|
}
|