mirror of
https://github.com/opencloud-eu/opencloud.git
synced 2026-02-09 05:28:56 -06:00
517 lines
15 KiB
Go
517 lines
15 KiB
Go
/*
|
|
* Copyright 2017-2019 Kopano and its licensors
|
|
*
|
|
* 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 identifier
|
|
|
|
import (
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/sirupsen/logrus"
|
|
|
|
"github.com/libregraph/lico/identity/authorities"
|
|
"github.com/libregraph/lico/utils"
|
|
)
|
|
|
|
func (i *Identifier) staticHandler(handler http.Handler, cache bool) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
addCommonResponseHeaders(rw.Header())
|
|
if cache {
|
|
rw.Header().Set("Cache-Control", "max-age=3153600, public")
|
|
} else {
|
|
rw.Header().Set("Cache-Control", "no-cache, max-age=0, public")
|
|
}
|
|
if strings.HasSuffix(req.URL.Path, "/") {
|
|
// Do not serve folder-ish resources.
|
|
i.ErrorPage(rw, http.StatusNotFound, "", "")
|
|
return
|
|
}
|
|
handler.ServeHTTP(rw, req)
|
|
})
|
|
}
|
|
|
|
func (i *Identifier) secureHandler(handler http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
|
|
var err error
|
|
|
|
// TODO(longsleep): Add support for X-Forwareded-Host with trusted proxy.
|
|
// NOTE: this does not protect from DNS rebinding. Protection for that
|
|
// should be added at the frontend proxy.
|
|
requiredHost := req.Host
|
|
|
|
// This follows https://www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet
|
|
for {
|
|
if req.Header.Get("Kopano-Konnect-XSRF") != "1" {
|
|
err = fmt.Errorf("missing xsrf header")
|
|
break
|
|
}
|
|
|
|
origin := req.Header.Get("Origin")
|
|
referer := req.Header.Get("Referer")
|
|
|
|
// Require either Origin and Referer header.
|
|
// NOTE(longsleep): Firefox does not send Origin header for POST
|
|
// requests when on the same domain - this is fuck (tm). See
|
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=446344 for reference.
|
|
if origin == "" && referer == "" {
|
|
err = fmt.Errorf("missing origin or referer header")
|
|
break
|
|
}
|
|
|
|
if origin != "" {
|
|
originURL, urlParseErr := url.Parse(origin)
|
|
if urlParseErr != nil {
|
|
err = fmt.Errorf("invalid origin value: %v", urlParseErr)
|
|
break
|
|
}
|
|
if originURL.Host != requiredHost {
|
|
err = fmt.Errorf("origin does not match request URL")
|
|
break
|
|
}
|
|
} else if referer != "" {
|
|
refererURL, urlParseErr := url.Parse(referer)
|
|
if urlParseErr != nil {
|
|
err = fmt.Errorf("invalid referer value: %v", urlParseErr)
|
|
break
|
|
}
|
|
if refererURL.Host != requiredHost {
|
|
err = fmt.Errorf("referer does not match request URL")
|
|
break
|
|
}
|
|
} else {
|
|
i.logger.WithFields(logrus.Fields{
|
|
"host": requiredHost,
|
|
"user-agent": req.UserAgent(),
|
|
}).Warn("identifier HTTP request is insecure with no Origin and Referer")
|
|
}
|
|
|
|
handler.ServeHTTP(rw, req)
|
|
return
|
|
}
|
|
|
|
if err != nil {
|
|
i.logger.WithError(err).WithFields(logrus.Fields{
|
|
"host": requiredHost,
|
|
"referer": req.Referer(),
|
|
"user-agent": req.UserAgent(),
|
|
"origin": req.Header.Get("Origin"),
|
|
}).Warn("rejecting identifier HTTP request")
|
|
}
|
|
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "")
|
|
})
|
|
}
|
|
|
|
func (i *Identifier) handleIdentifier(rw http.ResponseWriter, req *http.Request) {
|
|
addCommonResponseHeaders(rw.Header())
|
|
addNoCacheResponseHeaders(rw.Header())
|
|
|
|
err := req.ParseForm()
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request")
|
|
return
|
|
}
|
|
|
|
switch req.Form.Get("flow") {
|
|
case FlowOIDC, FlowOAuth, "":
|
|
if req.Form.Get("identifier") != MustBeSignedIn {
|
|
// Check if there is a default authority, if so use that.
|
|
authority := i.authorities.Default(req.Context())
|
|
if authority != nil {
|
|
switch authority.AuthorityType {
|
|
case authorities.AuthorityTypeOIDC:
|
|
i.writeOAuth2Start(rw, req, authority)
|
|
case authorities.AuthorityTypeSAML2:
|
|
i.writeSAML2Start(rw, req, authority)
|
|
default:
|
|
i.ErrorPage(rw, http.StatusNotImplemented, "", "unknown authority type")
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show default.
|
|
i.writeWebappIndexHTML(rw, req)
|
|
}
|
|
|
|
func (i *Identifier) handleLogon(rw http.ResponseWriter, req *http.Request) {
|
|
decoder := json.NewDecoder(req.Body)
|
|
var r LogonRequest
|
|
err := decoder.Decode(&r)
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode logon request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
|
|
return
|
|
}
|
|
|
|
var user *IdentifiedUser
|
|
response := &LogonResponse{
|
|
State: r.State,
|
|
}
|
|
|
|
addNoCacheResponseHeaders(rw.Header())
|
|
|
|
record := &Record{}
|
|
|
|
if r.Hello != nil {
|
|
err = r.Hello.parse()
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to parse logon request hello")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request values")
|
|
return
|
|
}
|
|
record.HelloRequest = r.Hello
|
|
}
|
|
|
|
req = req.WithContext(NewRecordContext(req.Context(), record))
|
|
|
|
// Params is an array like this [$username, $password, $mode], defining a
|
|
// extensible way to extend login modes over time. The minimal length of
|
|
// the params array is 1 with only [$username]. Second field is the password
|
|
// but its interpretation depends on the third field ($mode). The rest of the
|
|
// fields are mode specific.
|
|
params := r.Params
|
|
for {
|
|
paramSize := len(params)
|
|
if paramSize == 0 {
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "params required")
|
|
break
|
|
}
|
|
|
|
if paramSize >= 3 && params[1] == "" && params[2] == ModeLogonUsernameEmptyPasswordCookie {
|
|
// Special mode to allow when same user is logged in via cookie. This
|
|
// is used in the select account page logon flow with empty password.
|
|
identifiedUser, cookieErr := i.GetUserFromLogonCookie(req.Context(), req, 0, true)
|
|
if cookieErr != nil {
|
|
i.logger.WithError(cookieErr).Debugln("identifier failed to decode logon cookie in logon request")
|
|
}
|
|
if identifiedUser != nil {
|
|
if identifiedUser.Username() == params[0] {
|
|
user = identifiedUser
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
audience := ""
|
|
if r.Hello != nil {
|
|
audience = r.Hello.ClientID
|
|
}
|
|
|
|
if paramSize < 3 {
|
|
// Unsupported logon mode.
|
|
break
|
|
}
|
|
if params[1] == "" {
|
|
// Empty password, stop here - never allowed in any mode.
|
|
break
|
|
}
|
|
|
|
switch params[2] {
|
|
case ModeLogonUsernamePassword:
|
|
// Username and password validation mode.
|
|
logonedUser, logonErr := i.logonUser(req.Context(), audience, params[0], params[1])
|
|
if logonErr != nil {
|
|
i.logger.WithError(logonErr).Errorln("identifier failed to logon with backend")
|
|
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to logon")
|
|
return
|
|
}
|
|
user = logonedUser
|
|
|
|
default:
|
|
i.logger.Debugln("identifier unknown logon mode: %v", params[2])
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
if user == nil || user.Subject() == "" {
|
|
rw.Header().Set("Kopano-Konnect-State", response.State)
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
// Get user meta data.
|
|
// TODO(longsleep): This is an additional request to the backend. This
|
|
// should be avoided. Best would be if the backend would return everything
|
|
// in one shot (TODO in core).
|
|
err = i.updateUser(req.Context(), user, nil)
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to update user data in logon request")
|
|
}
|
|
|
|
// Set logon time.
|
|
user.logonAt = time.Now()
|
|
|
|
if r.Hello != nil {
|
|
hello, errHello := i.writeHelloResponse(rw, req, r.Hello, user)
|
|
if errHello != nil {
|
|
i.logger.WithError(errHello).Debugln("rejecting identifier logon request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", errHello.Error())
|
|
return
|
|
}
|
|
if !hello.Success {
|
|
rw.Header().Set("Kopano-Konnect-State", response.State)
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
response.Hello = hello
|
|
}
|
|
|
|
err = i.SetUserToLogonCookie(req.Context(), rw, user)
|
|
if err != nil {
|
|
i.logger.WithError(err).Errorln("failed to serialize logon ticket")
|
|
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize logon ticket")
|
|
return
|
|
}
|
|
|
|
response.Success = true
|
|
|
|
err = utils.WriteJSON(rw, http.StatusOK, response, "")
|
|
if err != nil {
|
|
i.logger.WithError(err).Errorln("logon request failed writing response")
|
|
}
|
|
}
|
|
|
|
func (i *Identifier) handleLogoff(rw http.ResponseWriter, req *http.Request) {
|
|
decoder := json.NewDecoder(req.Body)
|
|
var r StateRequest
|
|
err := decoder.Decode(&r)
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode logoff request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
|
|
return
|
|
}
|
|
|
|
addNoCacheResponseHeaders(rw.Header())
|
|
|
|
ctx := req.Context()
|
|
u, err := i.GetUserFromLogonCookie(ctx, req, 0, false)
|
|
if err != nil {
|
|
i.logger.WithError(err).Warnln("identifier logoff failed to get logon from ticket")
|
|
}
|
|
err = i.UnsetLogonCookie(ctx, u, rw)
|
|
if err != nil {
|
|
i.logger.WithError(err).Errorln("identifier failed to set logoff ticket")
|
|
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to set logoff ticket")
|
|
return
|
|
}
|
|
|
|
response := &StateResponse{
|
|
State: r.State,
|
|
Success: true,
|
|
}
|
|
|
|
err = utils.WriteJSON(rw, http.StatusOK, response, "")
|
|
if err != nil {
|
|
i.logger.WithError(err).Errorln("logoff request failed writing response")
|
|
}
|
|
}
|
|
|
|
func (i *Identifier) handleConsent(rw http.ResponseWriter, req *http.Request) {
|
|
decoder := json.NewDecoder(req.Body)
|
|
var r ConsentRequest
|
|
err := decoder.Decode(&r)
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode consent request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
|
|
return
|
|
}
|
|
|
|
addNoCacheResponseHeaders(rw.Header())
|
|
|
|
consent := &Consent{
|
|
Allow: r.Allow,
|
|
}
|
|
if r.Allow {
|
|
consent.RawScope = r.RawScope
|
|
}
|
|
|
|
err = i.SetConsentToConsentCookie(req.Context(), rw, &r, consent)
|
|
if err != nil {
|
|
i.logger.WithError(err).Errorln("failed to serialize consent ticket")
|
|
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to serialize consent ticket")
|
|
return
|
|
}
|
|
|
|
if !r.Allow {
|
|
rw.Header().Set("Kopano-Konnect-State", r.State)
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
|
|
response := &StateResponse{
|
|
State: r.State,
|
|
Success: true,
|
|
}
|
|
|
|
err = utils.WriteJSON(rw, http.StatusOK, response, "")
|
|
if err != nil {
|
|
i.logger.WithError(err).Errorln("logoff request failed writing response")
|
|
}
|
|
}
|
|
|
|
func (i *Identifier) handleHello(rw http.ResponseWriter, req *http.Request) {
|
|
decoder := json.NewDecoder(req.Body)
|
|
var r HelloRequest
|
|
err := decoder.Decode(&r)
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode hello request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request JSON")
|
|
return
|
|
}
|
|
err = r.parse()
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to parse hello request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to parse request values")
|
|
return
|
|
}
|
|
|
|
addNoCacheResponseHeaders(rw.Header())
|
|
|
|
response, err := i.writeHelloResponse(rw, req, &r, nil)
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("rejecting identifier hello request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", err.Error())
|
|
return
|
|
}
|
|
|
|
err = utils.WriteJSON(rw, http.StatusOK, response, "")
|
|
if err != nil {
|
|
i.logger.WithError(err).Errorln("hello request failed writing response")
|
|
}
|
|
}
|
|
|
|
func (i *Identifier) handleTrampolin(rw http.ResponseWriter, req *http.Request) {
|
|
if !strings.HasSuffix(req.URL.Path, ".js") {
|
|
err := req.ParseForm()
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode trampolin request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
|
return
|
|
}
|
|
|
|
sd, err := i.GetStateFromStateCookie(req.Context(), rw, req, "trampolin", req.Form.Get("state"))
|
|
if err != nil {
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", err.Error())
|
|
return
|
|
}
|
|
if sd == nil || sd.Trampolin == nil {
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "no state")
|
|
return
|
|
}
|
|
|
|
scope := sd.Trampolin.Scope
|
|
uri, _ := url.Parse(sd.Trampolin.URI)
|
|
sd.Trampolin = nil
|
|
|
|
err = i.SetStateToStateCookie(req.Context(), rw, scope, sd)
|
|
if err != nil {
|
|
i.logger.WithError(err).Errorln("failed to write trampolin state cookie")
|
|
i.ErrorPage(rw, http.StatusInternalServerError, "", "failed to write trampolin state cookie")
|
|
return
|
|
}
|
|
|
|
i.writeTrampolinHTML(rw, req, uri)
|
|
} else {
|
|
i.writeTrampolinScript(rw, req)
|
|
}
|
|
}
|
|
|
|
func (i *Identifier) handleOAuth2Start(rw http.ResponseWriter, req *http.Request) {
|
|
err := req.ParseForm()
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode oauth2 start request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
|
return
|
|
}
|
|
|
|
var authority *authorities.Details
|
|
if authorityID := req.Form.Get("authority_id"); authorityID != "" {
|
|
authority, _ = i.authorities.Lookup(req.Context(), authorityID)
|
|
}
|
|
|
|
i.writeOAuth2Start(rw, req, authority)
|
|
}
|
|
|
|
func (i *Identifier) handleOAuth2Cb(rw http.ResponseWriter, req *http.Request) {
|
|
err := req.ParseForm()
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode oauth2 cb request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
|
return
|
|
}
|
|
|
|
i.writeOAuth2Cb(rw, req)
|
|
}
|
|
|
|
func (i *Identifier) handleSAML2Metadata(rw http.ResponseWriter, req *http.Request) {
|
|
authorityDetails := i.authorities.Default(req.Context())
|
|
if authorityDetails == nil || authorityDetails.AuthorityType != authorities.AuthorityTypeSAML2 {
|
|
i.ErrorPage(rw, http.StatusNotFound, "", "saml not configured")
|
|
return
|
|
}
|
|
|
|
metadata := authorityDetails.Metadata()
|
|
if metadata == nil {
|
|
i.ErrorPage(rw, http.StatusNotFound, "", "saml has no meta data")
|
|
return
|
|
}
|
|
|
|
buf, _ := xml.MarshalIndent(metadata, "", " ")
|
|
rw.Header().Set("Content-Type", "application/samlmetadata+xml")
|
|
rw.WriteHeader(http.StatusOK)
|
|
rw.Write([]byte(xml.Header))
|
|
rw.Write(buf)
|
|
}
|
|
|
|
func (i *Identifier) handleSAML2AssertionConsumerService(rw http.ResponseWriter, req *http.Request) {
|
|
err := req.ParseForm()
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode saml2 acs request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
|
return
|
|
}
|
|
|
|
i.writeSAML2AssertionConsumerService(rw, req)
|
|
}
|
|
|
|
func (i *Identifier) handleSAML2SingleLogoutService(rw http.ResponseWriter, req *http.Request) {
|
|
err := req.ParseForm()
|
|
if err != nil {
|
|
i.logger.WithError(err).Debugln("identifier failed to decode saml2 slo request")
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "failed to decode request parameters")
|
|
return
|
|
}
|
|
|
|
if _, ok := req.Form["SAMLRequest"]; ok {
|
|
i.writeSAMLSingleLogoutServiceRequest(rw, req)
|
|
} else if _, ok := req.Form["SAMLResponse"]; ok {
|
|
i.writeSAMLSingleLogoutServiceResponse(rw, req)
|
|
} else {
|
|
i.ErrorPage(rw, http.StatusBadRequest, "", "neither SAMLRequest nor SAMLResponse parameter found")
|
|
}
|
|
}
|