Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# Configuration file for golangci-lint
#
# https://github.com/golangci/golangci-lint
#
# fighting with false positives?
# https://github.com/golangci/golangci-lint#nolint
linters:
enable:
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification [fast: true, auto-fix: true]
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports [fast: true, auto-fix: true]
- gosec # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases [fast: true, auto-fix: false]
- misspell # Finds commonly misspelled English words in comments [fast: true, auto-fix: true]
- deadcode # Finds unused code [fast: true, auto-fix: false]
- golint # Golint differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes [fast: true, auto-fix: false]
disable:
# TODO(ross): fix errors reported by these checkers and enable them
- bodyclose # checks whether HTTP response body is closed successfully [fast: false, auto-fix: false]
- depguard # Go linter that checks if package imports are in a list of acceptable packages [fast: true, auto-fix: false]
- dupl # Tool for code clone detection [fast: true, auto-fix: false]
- errcheck # Inspects source code for security problems [fast: true, auto-fix: false]
- gochecknoglobals # Checks that no globals are present in Go code [fast: true, auto-fix: false]
- gochecknoinits # Checks that no init functions are present in Go code [fast: true, auto-fix: false]
- goconst # Finds repeated strings that could be replaced by a constant [fast: true, auto-fix: false]
- gocritic # The most opinionated Go source code linter [fast: true, auto-fix: false]
- gocyclo # Computes and checks the cyclomatic complexity of functions [fast: true, auto-fix: false]
- gosimple # Linter for Go source code that specializes in simplifying a code [fast: false, auto-fix: false]
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string [fast: false, auto-fix: false]
- ineffassign # Detects when assignments to existing variables are not used [fast: true, auto-fix: false]
- interfacer # Linter that suggests narrower interface types [fast: false, auto-fix: false]
- lll # Reports long lines [fast: true, auto-fix: false]
- maligned # Tool to detect Go structs that would take less memory if their fields were sorted [fast: true, auto-fix: false]
- nakedret # Finds naked returns in functions greater than a specified function length [fast: true, auto-fix: false]
- prealloc # Finds slice declarations that could potentially be preallocated [fast: true, auto-fix: false]
- scopelint # Scopelint checks for unpinned variables in go programs [fast: true, auto-fix: false]
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks [fast: false, auto-fix: false]
- structcheck # Finds unused struct fields [fast: true, auto-fix: false]
- stylecheck # Stylecheck is a replacement for golint [fast: false, auto-fix: false]
- typecheck # Like the front-end of a Go compiler, parses and type-checks Go code [fast: true, auto-fix: false]
- unconvert # Remove unnecessary type conversions [fast: true, auto-fix: false]
- unparam # Reports unused function parameters [fast: false, auto-fix: false]
- unused # Checks Go code for unused constants, variables, functions and types [fast: false, auto-fix: false]
- varcheck # Finds unused global variables and constants [fast: true, auto-fix: false]
linters-settings:
goimports:
local-prefixes: github.com/crewjam/saml
govet:
disable:
- shadow
enable:
- asmdecl
- assign
- atomic
- bools
- buildtag
- cgocall
- composites
- copylocks
- errorsas
- httpresponse
- loopclosure
- lostcancel
- nilfunc
- printf
- shift
- stdmethods
- structtag
- tests
- unmarshal
- unreachable
- unsafeptr
- unusedresult
issues:
exclude-use-default: false
exclude:
- G104 # 'Errors unhandled. (gosec)
+13
View File
@@ -0,0 +1,13 @@
language: go
env: GO111MODULE=on
before_script:
- curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh| sh -s -- -b $(go env GOPATH)/bin v1.21.0
script:
- golangci-lint run
- go test -v ./...
go:
- tip
+23
View File
@@ -0,0 +1,23 @@
Copyright (c) 2016, Ross Kinder
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+152
View File
@@ -0,0 +1,152 @@
# httperr
[![GoDoc](https://godoc.org/github.com/crewjam/httperr?status.svg)](https://godoc.org/github.com/crewjam/httperr)
[![Build Status](https://travis-ci.org/crewjam/httperr.svg?branch=master)](https://travis-ci.org/crewjam/httperr)
Package httperr provides utilities for handling error conditions in http
clients and servers.
## Client
This package provides an http.Client that returns errors for requests that return
a status code >= 400. It lets you turn code like this:
```golang
func GetFoo() {
req, _ := http.NewRequest("GET", "https://api.example.com/foo", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("api call failed: %d", resp.StatusCode)
}
// ....
}
```
Into code like this:
```golang
func GetFoo() {
req, _ := http.NewRequest("GET", "https://api.example.com/foo", nil)
resp, err := httperr.Client().Do(req)
if err != nil {
return nil, err
}
// ....
}
```
Wow, three whole lines. Life changing, eh? But wait, there's more!
You can have the client parse structured errors returned from an API:
```golang
type APIError struct {
Message string `json:"message"`
Code string `json:"code"`
}
func (a APIError) Error() string {
// APIError must implement the Error interface
return fmt.Sprintf("%s (code %d)", a.Message, a.Code)
}
func GetFoo() {
client := httperr.Client(http.DefaultClient, httperr.JSON(APIError{}))
req, _ := http.NewRequest("GET", "https://api.example.com/foo", nil)
resp, err := client.Do(req)
if err != nil {
// If the server returned a status code >= 400, and the response was valid
// JSON for APIError, then err is an *APIErr.
return nil, err
}
// ....
}
```
## Server
Error handling in Go's http.Handler and http.HandlerFunc can be tricky. I often found myself wishing that we could just return an `err` and be done with things.
This package provides an adapter function which turns:
```golang
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
remoteUser, err := s.Auth.RequireUser(w, r)
if err != nil {
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
return
}
user, err := s.Storage.Get(remoteUser.Name)
if err != nil {
log.Printf("ERROR: cannot fetch user: %s", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(user)
}
```
Into this:
```golang
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
remoteUser, err := s.Auth.RequireUser(w, r)
if err != nil {
return httperr.Unauthorized
}
user, err := s.Storage.Get(remoteUser.Name)
if err != nil {
return err
}
return json.NewEncoder(w).Encode(user)
}
```
Life changing? Probably not, but it seems to remove a lot of redundancy and make control flow in web servers simpler.
You can also wrap your calls with middleware that allow you to provide custom handling of errors that are returned from your handlers, but also >= 400 status codes issued by handlers that don't return errors.
```golang
htmlErrorTmpl := template.Must(template.New("err").Parse(errorTemplate))
handler := httperr.Middleware{
OnError: func(w http.ResponseWriter, r *http.Request, err error) error {
log.Printf("REQUEST ERROR: %s", err)
if acceptHeaderContainsTextHTML(r) {
htmlErrorTmpl.Execute(w, struct{ Error error }{Error: err})
return nil // nil means we've handled the error
}
return err // fall back to the default
},
Handler: httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
if r.Method != "POST" {
return httperr.MethodNotAllowed
}
var reqBody RequestBody
if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil {
return httperr.Public{
StatusCode: http.StatusBadRequest,
Err: err,
}
}
if reqBody.Count <= 0 {
// The client won't see this, instead OnError will be called with a httperr.Response containing
// the response. The OnError function can decide to write the error, or replace it with it's own.
w.WriteHeader(http.StatusConflict)
fmt.Fprintln(w, "an obscure internal error happened, but the user doesn't want to see this.")
return nil
}
// ...
return nil
}),
}
```
+120
View File
@@ -0,0 +1,120 @@
package httperr
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"reflect"
)
// ClientArg is an argument to Client
type ClientArg func(xport *Transport)
// Client returns an http.Client that wraps client with
// an error handling transport.
func Client(next *http.Client, args ...ClientArg) *http.Client {
xport := Transport{Next: next.Transport}
for _, arg := range args {
arg(&xport)
}
rv := *next
rv.Transport = xport
return &rv
}
// DefaultClient returns an http.Client that wraps the default
// http.Client with an error handling transport.
func DefaultClient() *http.Client {
return Client(http.DefaultClient)
}
var _ http.RoundTripper = Transport{}
// Transport is an http.RoundTripper that intercepts responses where
// the StatusCode >= 400 and returns a Response{}.
//
// If ErrorFactory is specified it should return an error that can be used
// to unmarshal a JSON error response. This is useful when a web service
// offers structured error information. If the error structure cannot be
// unmarshalled, then a regular Response error is returned.
//
// type APIError struct {
// Code string `json:"code"`
// Message string `json:"message"`
// }
//
// func (a APIError) Error() string {
// return fmt.Sprintf("%s (%d)", a.Message, a.Code)
// }
//
// t := Transport{
// ErrorFactory: func() error {
// return &APIError{}
// },
// }
//
type Transport struct {
Next http.RoundTripper
OnError func(req *http.Request, resp *http.Response) error
}
// RoundTrip implements http.RoundTripper.
func (t Transport) RoundTrip(req *http.Request) (*http.Response, error) {
next := t.Next
if next == nil {
next = http.DefaultTransport
}
resp, err := next.RoundTrip(req)
if err != nil {
return nil, err
}
if resp.StatusCode < 400 {
return resp, nil
}
if t.OnError != nil {
if err := t.OnError(req, resp); err != nil {
return nil, err
}
}
return nil, Response(*resp)
}
// JSON returns a ClientArg that specifies a function that
// handles errors structured as a JSON object.
func JSON(errStruct error) ClientArg {
typ := reflect.TypeOf(errStruct)
if typ.Kind() != reflect.Struct {
panic("JSON() argument must be a structure")
}
e := reflect.New(typ).Interface()
_ = e.(error) // panic if errStruct
return func(xport *Transport) {
xport.OnError = func(req *http.Request, resp *http.Response) error {
defer resp.Body.Close()
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
resp.Body = ioutil.NopCloser(bytes.NewReader(body))
jsonErrValue := reflect.New(typ)
unmarshalErr := json.Unmarshal(body, jsonErrValue.Interface())
if unmarshalErr == nil {
// jsonErrValue is a *Foo if errStruct == Foo{}
return jsonErrValue.Elem().Interface().(error)
}
// we failed to unmarshal the response body, so ignore the
// JSON error and proceed as if ErrorFactory was not provided.
return Response(*resp)
}
}
}
+73
View File
@@ -0,0 +1,73 @@
package httperr
var (
// BadRequest is an error that represents a static http.StatusBadRequest error
BadRequest = Value{StatusCode: 400}
// Unauthorized is an error that represents a static http.StatusUnauthorized error
Unauthorized = Value{StatusCode: 401}
// PaymentRequired is an error that represents a static http.StatusPaymentRequired error
PaymentRequired = Value{StatusCode: 402}
// Forbidden is an error that represents a static http.StatusForbidden error
Forbidden = Value{StatusCode: 403}
// NotFound is an error that represents a static http.StatusNotFound error
NotFound = Value{StatusCode: 404}
// MethodNotAllowed is an error that represents a static http.StatusMethodNotAllowed error
MethodNotAllowed = Value{StatusCode: 405}
// NotAcceptable is an error that represents a static http.StatusNotAcceptable error
NotAcceptable = Value{StatusCode: 406}
// ProxyAuthRequired is an error that represents a static http.StatusProxyAuthRequired error
ProxyAuthRequired = Value{StatusCode: 407}
// RequestTimeout is an error that represents a static http.StatusRequestTimeout error
RequestTimeout = Value{StatusCode: 408}
// Conflict is an error that represents a static http.StatusConflict error
Conflict = Value{StatusCode: 409}
// Gone is an error that represents a static http.StatusGone error
Gone = Value{StatusCode: 410}
// LengthRequired is an error that represents a static http.StatusLengthRequired error
LengthRequired = Value{StatusCode: 411}
// PreconditionFailed is an error that represents a static http.StatusPreconditionFailed error
PreconditionFailed = Value{StatusCode: 412}
// RequestEntityTooLarge is an error that represents a static http.StatusRequestEntityTooLarge error
RequestEntityTooLarge = Value{StatusCode: 413}
// RequestURITooLong is an error that represents a static http.StatusRequestURITooLong error
RequestURITooLong = Value{StatusCode: 414}
// UnsupportedMediaType is an error that represents a static http.StatusUnsupportedMediaType error
UnsupportedMediaType = Value{StatusCode: 415}
// RequestedRangeNotSatisfiable is an error that represents a static http.StatusRequestedRangeNotSatisfiable error
RequestedRangeNotSatisfiable = Value{StatusCode: 416}
// ExpectationFailed is an error that represents a static http.StatusExpectationFailed error
ExpectationFailed = Value{StatusCode: 417}
// Teapot is an error that represents a static http.StatusTeapot error
Teapot = Value{StatusCode: 418}
// TooManyRequests is an error that represents a static http.StatusTooManyRequests error
TooManyRequests = Value{StatusCode: 429}
// InternalServerError is an error that represents a static http.StatusInternalServerError error
InternalServerError = Value{StatusCode: 500}
// NotImplemented is an error that represents a static http.StatusNotImplemented error
NotImplemented = Value{StatusCode: 501}
// BadGateway is an error that represents a static http.StatusBadGateway error
BadGateway = Value{StatusCode: 502}
// ServiceUnavailable is an error that represents a static http.StatusServiceUnavailable error
ServiceUnavailable = Value{StatusCode: 503}
// GatewayTimeout is an error that represents a static http.StatusGatewayTimeout error
GatewayTimeout = Value{StatusCode: 504}
// HTTPVersionNotSupported is an error that represents a static http.StatusHTTPVersionNotSupported error
HTTPVersionNotSupported = Value{StatusCode: 505}
)
// New returns a new http error wrapping err with status statusCode.
func New(statusCode int, err error) error {
return Value{
StatusCode: statusCode,
Err: err,
}
}
// Public returns a new public http error wrapping err with status statusCode.
func Public(statusCode int, err error) error {
return Value{
Public: true,
StatusCode: statusCode,
Err: err,
}
}
+20
View File
@@ -0,0 +1,20 @@
package httperr
import "net/http"
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler object that calls f.
type HandlerFunc func(http.ResponseWriter, *http.Request) error
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if err := f(w, r); err != nil {
if v := r.Context().Value(onErrorIndex); v != nil {
v.(func(error))(err)
} else {
Write(w, r, err)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
// Package httperr implements an error object that speaks HTTP.
package httperr
import (
"errors"
"net/http"
pkgerrors "github.com/pkg/errors"
)
type statusCodeAndTexter interface {
StatusCodeAndText() (int, string)
}
// StatusCodeAndText returns the status code and text of the error
func StatusCodeAndText(err error) (int, string) {
if err == nil {
return http.StatusOK, http.StatusText(http.StatusOK)
}
err = pkgerrors.Cause(err)
var scater statusCodeAndTexter
if errors.As(err, &scater) {
return scater.StatusCodeAndText()
}
return http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError)
}
// Writer is an interface for things that know how to write themselves
// to an error response. This interface is implemented by Private and
// Public to provide default error pages.
type Writer interface {
error
WriteError(w http.ResponseWriter, r *http.Request)
}
// Write writes the specified error to w. If err is a Writer, then
// it's WriteError method is invoked to produce the response.
// Otherwise a generic "500 Internal Server Error" is written.
func Write(w http.ResponseWriter, r *http.Request, err error) {
err = pkgerrors.Cause(err)
var errWriter Writer
if errors.As(err, &errWriter) {
errWriter.WriteError(w, r)
return
}
genericErr := Value{
Err: err,
StatusCode: http.StatusInternalServerError,
}
genericErr.WriteError(w, r)
}
+62
View File
@@ -0,0 +1,62 @@
package httperr
import (
"context"
"net/http"
)
type onErrorIndexType int
const onErrorIndex onErrorIndexType = iota
// Middleware wraps the provided handler with middleware that captures errors which
// are returned from HandlerFunc, or reported via ReportError, and invokes the provided
// callback to render them. If the handler returns a status code >= 400, the response is
// captured and passed to OnError as a Response.
//
type Middleware struct {
// OnError is a function that is called then a request fails with an error. If this function
// returns nil, then the error is assumed to be handled. If it returns a non-nil error, then
// that error is written to the client with Write()
OnError func(w http.ResponseWriter, r *http.Request, err error) error
// Handler is the next handler
Handler http.Handler
}
func (m Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var unwrappedWriter = w
var wrappedWriter *basicWriter
if m.OnError != nil {
wrappedWriter, w = wrapWriter(w)
}
var didCallOnError bool
r = r.WithContext(context.WithValue(r.Context(), onErrorIndex, func(err error) {
if m.OnError != nil {
didCallOnError = true
handlerErr := m.OnError(unwrappedWriter, r, err)
if handlerErr != nil {
Write(unwrappedWriter, r, handlerErr)
}
}
}))
m.Handler.ServeHTTP(w, r)
if wrappedWriter != nil && wrappedWriter.statusCode >= 400 && !didCallOnError {
err := Response(*wrappedWriter.copy)
handlerErr := m.OnError(unwrappedWriter, r, err)
if handlerErr != nil {
Write(unwrappedWriter, r, handlerErr)
}
}
}
// ReportError reports the error to the function given in
// OnError.
func ReportError(r *http.Request, err error) {
if v := r.Context().Value(onErrorIndex); v != nil {
v.(func(error))(err)
}
}
+42
View File
@@ -0,0 +1,42 @@
package httperr
import (
"io"
"net/http"
)
// Response is an alias for http.Response that implements
// the error interface. Example:
//
// resp, err := http.Get("http://www.example.com")
// if err != nil {
// return err
// }
// if resp.StatusCode != http.StatusOK {
// return httperr.Response(*resp)
// }
// // ...
//
type Response http.Response
func (re Response) Error() string {
statusText := re.Status
if statusText == "" {
statusText = http.StatusText(re.StatusCode)
}
return statusText
}
// WriteError copies the Response to the ResponseWriter.
func (re Response) WriteError(w http.ResponseWriter, r *http.Request) {
for k, vv := range re.Header {
for _, v := range vv {
w.Header().Add(k, v)
}
}
w.WriteHeader(re.StatusCode)
io.Copy(w, re.Body)
}
var _ error = Response{}
var _ Writer = Response{}
+67
View File
@@ -0,0 +1,67 @@
// Package httperr implements an error object that speaks HTTP.
package httperr
import (
"fmt"
"net/http"
)
// Value is an Error that returns that status and code
// provided, and reveals the underlying wrapper error to
// the caller. The text of the error is rendered to the
// client in the body of the response, as well as in
// the X-Error header.
type Value struct {
Err error // the underlying error
StatusCode int // the HTTP status code. If not supplied, http.StatusInternalServerError is used.
Status string // the HTTP status text. If not supplied, http.StatusText(http.StatusCode) is used.
Public bool
Header http.Header // extra headers to add to the response (optional)
}
// StatusCodeAndText returns the status code and text of the error
func (e Value) StatusCodeAndText() (int, string) {
if e.StatusCode == 0 {
e.StatusCode = http.StatusInternalServerError
}
if e.Status == "" {
if e.Err != nil && e.Public {
e.Status = e.Err.Error()
} else {
e.Status = http.StatusText(e.StatusCode)
}
}
return e.StatusCode, e.Status
}
func (e Value) Error() string {
statusCode, statusText := StatusCodeAndText(e)
if e.Public {
return fmt.Sprintf("%d %s", statusCode, statusText)
}
return fmt.Sprintf("%d %s: %s", statusCode, statusText, e.Err.Error())
}
// WriteError writes an error response to w using the specified status code.
func (e Value) WriteError(w http.ResponseWriter, r *http.Request) {
for key, values := range e.Header {
w.Header().Del(key) // overwrite headers already in the response with the ones specified
for _, value := range values {
w.Header().Add(key, value)
}
}
code, message := e.StatusCodeAndText()
http.Error(w, message, code)
}
// Unwrap unwraps the Value error and returns the underlying error`
func (e Value) Unwrap() error {
return e.Err
}
var _ error = Value{}
var _ Writer = Value{}
var _ statusCodeAndTexter = Value{}
+119
View File
@@ -0,0 +1,119 @@
package httperr
// Note: The writer proxies in this file are a heavily modified version of
// code bearing the following message:
//
// Copyright (c) 2014, 2015, 2016 Carl Jackson (carl@avtok.com)
//
// MIT License
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import (
"bufio"
"bytes"
"io/ioutil"
"net"
"net/http"
)
// wrapWriter wraps an http.ResponseWriter, returning a proxy that
// tracks the response.
func wrapWriter(w http.ResponseWriter) (*basicWriter, http.ResponseWriter) {
_, isCloseNotifier := w.(http.CloseNotifier)
_, isFlusher := w.(http.Flusher)
_, isHijacker := w.(http.Hijacker)
bw := basicWriter{ResponseWriter: w}
if isCloseNotifier && isFlusher && isHijacker {
rv := fancyWriter{bw}
return &rv.basicWriter, &rv
}
if isFlusher {
rv := flushWriter{bw}
return &rv.basicWriter, &rv
}
return &bw, &bw
}
type basicWriter struct {
http.ResponseWriter
statusCode int
copy *http.Response
body *bytes.Buffer
}
func (b *basicWriter) WriteHeader(code int) {
b.statusCode = code
if code < 400 {
b.ResponseWriter.WriteHeader(code)
return
}
b.copy = &http.Response{
StatusCode: code,
Header: b.ResponseWriter.Header(),
}
}
func (b *basicWriter) Write(buf []byte) (int, error) {
if b.copy == nil {
return b.ResponseWriter.Write(buf)
}
if b.body == nil {
b.body = &bytes.Buffer{}
b.copy.Body = ioutil.NopCloser(b.body)
}
return b.body.Write(buf)
}
type fancyWriter struct {
basicWriter
}
func (f *fancyWriter) CloseNotify() <-chan bool {
cn := f.basicWriter.ResponseWriter.(http.CloseNotifier)
return cn.CloseNotify()
}
func (f *fancyWriter) Flush() {
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl.Flush()
}
func (f *fancyWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj := f.basicWriter.ResponseWriter.(http.Hijacker)
return hj.Hijack()
}
var _ http.CloseNotifier = &fancyWriter{}
var _ http.Flusher = &fancyWriter{}
var _ http.Hijacker = &fancyWriter{}
type flushWriter struct {
basicWriter
}
func (f *flushWriter) Flush() {
fl := f.basicWriter.ResponseWriter.(http.Flusher)
fl.Flush()
}
var _ http.Flusher = &flushWriter{}
+7
View File
@@ -0,0 +1,7 @@
coverage.out
coverage.html
vendor/
# IDE-specific settings
.idea
.vscode
+72
View File
@@ -0,0 +1,72 @@
# Configuration file for golangci-lint
#
# https://github.com/golangci/golangci-lint
#
# fighting with false positives?
# https://github.com/golangci/golangci-lint#nolint
linters:
enable:
- bodyclose # checks whether HTTP response body is closed successfully [fast: false, auto-fix: false]
- errcheck # Inspects source code for security problems [fast: true, auto-fix: false]
- gocritic # The most opinionated Go source code linter [fast: true, auto-fix: false]
- gocyclo # Computes and checks the cyclomatic complexity of functions [fast: true, auto-fix: false]
- gofmt # Gofmt checks whether code was gofmt-ed. By default this tool runs with -s option to check for code simplification [fast: true, auto-fix: true]
- goimports # Goimports does everything that gofmt does. Additionally it checks unused imports [fast: true, auto-fix: true]
- gosec # Errcheck is a program for checking for unchecked errors in go programs. These unchecked errors can be critical bugs in some cases [fast: true, auto-fix: false]
- gosimple # Linter for Go source code that specializes in simplifying a code [fast: false, auto-fix: false]
- govet # Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string [fast: false, auto-fix: false]
- ineffassign # Detects when assignments to existing variables are not used [fast: true, auto-fix: false]
- misspell # Finds commonly misspelled English words in comments [fast: true, auto-fix: true]
- nakedret # Finds naked returns in functions greater than a specified function length [fast: true, auto-fix: false]
- prealloc # Finds slice declarations that could potentially be preallocated [fast: true, auto-fix: false]
- revive # Golint differs from gofmt. Gofmt reformats Go source code, whereas golint prints out style mistakes [fast: true, auto-fix: false]
- staticcheck # Staticcheck is a go vet on steroids, applying a ton of static analysis checks [fast: false, auto-fix: false]
- stylecheck # Stylecheck is a replacement for golint [fast: false, auto-fix: false]
- typecheck # Like the front-end of a Go compiler, parses and type-checks Go code [fast: true, auto-fix: false]
- unconvert # Remove unnecessary type conversions [fast: true, auto-fix: false]
- unparam # Reports unused function parameters [fast: false, auto-fix: false]
- unused # Checks Go code for unused constants, variables, functions and types [fast: false, auto-fix: false]
disable:
# TODO(ross): fix errors reported by these checkers and enable them
- dupl # Tool for code clone detection [fast: true, auto-fix: false]
- gochecknoglobals # Checks that no globals are present in Go code [fast: true, auto-fix: false]
- gochecknoinits # Checks that no init functions are present in Go code [fast: true, auto-fix: false]
- goconst # Finds repeated strings that could be replaced by a constant [fast: true, auto-fix: false]
- lll # Reports long lines [fast: true, auto-fix: false]
- depguard # Go linter that checks if package imports are in a list of acceptable packages [fast: true, auto-fix: false]
linters-settings:
goimports:
local-prefixes: github.com/crewjam/saml
govet:
disable:
- shadow
enable:
- asmdecl
- assign
- atomic
- bools
- buildtag
- cgocall
- composites
- copylocks
- errorsas
- httpresponse
- loopclosure
- lostcancel
- nilfunc
- printf
- shift
- stdmethods
- structtag
- tests
- unmarshal
- unreachable
- unsafeptr
- unusedresult
issues:
exclude-use-default: false
exclude:
- G104 # 'Errors unhandled. (gosec)
+23
View File
@@ -0,0 +1,23 @@
Copyright (c) 2015, Ross Kinder
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+157
View File
@@ -0,0 +1,157 @@
# SAML
[![](https://godoc.org/github.com/crewjam/saml?status.svg)](http://godoc.org/github.com/crewjam/saml)
![Build Status](https://github.com/crewjam/saml/workflows/Presubmit/badge.svg)
Package saml contains a partial implementation of the SAML standard in golang.
SAML is a standard for identity federation, i.e. either allowing a third party to authenticate your users or allowing third parties to rely on us to authenticate their users.
## Introduction
In SAML parlance an **Identity Provider** (IDP) is a service that knows how to authenticate users. A **Service Provider** (SP) is a service that delegates authentication to an IDP. If you are building a service where users log in with someone else's credentials, then you are a **Service Provider**. This package supports implementing both service providers and identity providers.
The core package contains the implementation of SAML. The package samlsp provides helper middleware suitable for use in Service Provider applications. The package samlidp provides a rudimentary IDP service that is useful for testing or as a starting point for other integrations.
## Getting Started as a Service Provider
Let us assume we have a simple web application to protect. We'll modify this application so it uses SAML to authenticate users.
```golang
package main
import (
"fmt"
"net/http"
)
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, World!")
}
func main() {
app := http.HandlerFunc(hello)
http.Handle("/hello", app)
http.ListenAndServe(":8000", nil)
}
```
Each service provider must have an self-signed X.509 key pair established. You can generate your own with something like this:
openssl req -x509 -newkey rsa:2048 -keyout myservice.key -out myservice.cert -days 365 -nodes -subj "/CN=myservice.example.com"
We will use `samlsp.Middleware` to wrap the endpoint we want to protect. Middleware provides both an `http.Handler` to serve the SAML specific URLs **and** a set of wrappers to require the user to be logged in. We also provide the URL where the service provider can fetch the metadata from the IDP at startup. In our case, we'll use [samltest.id](https://samltest.id/), an identity provider designed for testing.
```golang
package main
import (
"context"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"net/url"
"github.com/crewjam/saml/samlsp"
)
func hello(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %s!", samlsp.AttributeFromContext(r.Context(), "displayName"))
}
func main() {
keyPair, err := tls.LoadX509KeyPair("myservice.cert", "myservice.key")
if err != nil {
panic(err) // TODO handle error
}
keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
if err != nil {
panic(err) // TODO handle error
}
idpMetadataURL, err := url.Parse("https://samltest.id/saml/idp")
if err != nil {
panic(err) // TODO handle error
}
idpMetadata, err := samlsp.FetchMetadata(context.Background(), http.DefaultClient,
*idpMetadataURL)
if err != nil {
panic(err) // TODO handle error
}
rootURL, err := url.Parse("http://localhost:8000")
if err != nil {
panic(err) // TODO handle error
}
samlSP, _ := samlsp.New(samlsp.Options{
URL: *rootURL,
Key: keyPair.PrivateKey.(*rsa.PrivateKey),
Certificate: keyPair.Leaf,
IDPMetadata: idpMetadata,
})
app := http.HandlerFunc(hello)
http.Handle("/hello", samlSP.RequireAccount(app))
http.Handle("/saml/", samlSP)
http.ListenAndServe(":8000", nil)
}
```
Next we'll have to register our service provider with the identity provider to establish trust from the service provider to the IDP. For [samltest.id](https://samltest.id/), you can do something like:
mdpath=saml-test-$USER-$HOST.xml
curl localhost:8000/saml/metadata > $mdpath
Navigate to https://samltest.id/upload.php and upload the file you fetched.
Now you should be able to authenticate. The flow should look like this:
1. You browse to `localhost:8000/hello`
1. The middleware redirects you to `https://samltest.id/idp/profile/SAML2/Redirect/SSO`
1. samltest.id prompts you for a username and password.
1. samltest.id returns you an HTML document which contains an HTML form setup to POST to `localhost:8000/saml/acs`. The form is automatically submitted if you have javascript enabled.
1. The local service validates the response, issues a session cookie, and redirects you to the original URL, `localhost:8000/hello`.
1. This time when `localhost:8000/hello` is requested there is a valid session and so the main content is served.
## Getting Started as an Identity Provider
Please see `example/idp/` for a substantially complete example of how to use the library and helpers to be an identity provider.
## Support
The SAML standard is huge and complex with many dark corners and strange, unused features. This package implements the most commonly used subset of these features required to provide a single sign on experience. The package supports at least the subset of SAML known as [interoperable SAML](https://kantarainitiative.github.io/SAMLprofiles/saml2int.html).
This package supports the **Web SSO** profile. Message flows from the service provider to the IDP are supported using the **HTTP Redirect** binding and the **HTTP POST** binding. Message flows from the IDP to the service provider are supported via the **HTTP POST** binding.
The package can produce signed SAML assertions, and can validate both signed and encrypted SAML assertions. It does not support signed or encrypted requests.
## RelayState
The _RelayState_ parameter allows you to pass user state information across the authentication flow. The most common use for this is to allow a user to request a deep link into your site, be redirected through the SAML login flow, and upon successful completion, be directed to the originally requested link, rather than the root.
Unfortunately, _RelayState_ is less useful than it could be. Firstly, it is **not** authenticated, so anything you supply must be signed to avoid XSS or CSRF. Secondly, it is limited to 80 bytes in length, which precludes signing. (See section 3.6.3.1 of SAMLProfiles.)
## References
The SAML specification is a collection of PDFs (sadly):
- [SAMLCore](http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf) defines data types.
- [SAMLBindings](http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf) defines the details of the HTTP requests in play.
- [SAMLProfiles](http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf) describes data flows.
- [SAMLConformance](http://docs.oasis-open.org/security/saml/v2.0/saml-conformance-2.0-os.pdf) includes a support matrix for various parts of the protocol.
[SAMLtest](https://samltest.id/) is a testing ground for SAML service and identity providers.
## Security Issues
Please do not report security issues in the issue tracker. Rather, please contact me directly at ross@kndr.org ([PGP Key `78B6038B3B9DFB88`](https://keybase.io/crewjam)). If your issue is *not* a security issue, please use the issue tracker so other contributors can help.
+128
View File
@@ -0,0 +1,128 @@
package saml
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// Duration is a time.Duration that uses the xsd:duration format for text
// marshalling and unmarshalling.
type Duration time.Duration
// MarshalText implements the encoding.TextMarshaler interface.
func (d Duration) MarshalText() ([]byte, error) {
if d == 0 {
return nil, nil
}
out := "PT"
if d < 0 {
d *= -1
out = "-" + out
}
h := time.Duration(d) / time.Hour
m := time.Duration(d) % time.Hour / time.Minute
s := time.Duration(d) % time.Minute / time.Second
ns := time.Duration(d) % time.Second
if h > 0 {
out += fmt.Sprintf("%dH", h)
}
if m > 0 {
out += fmt.Sprintf("%dM", m)
}
if s > 0 || ns > 0 {
out += fmt.Sprintf("%d", s)
if ns > 0 {
out += strings.TrimRight(fmt.Sprintf(".%09d", ns), "0")
}
out += "S"
}
return []byte(out), nil
}
const (
day = 24 * time.Hour
month = 30 * day // Assumed to be 30 days.
year = 365 * day // Assumed to be non-leap year.
)
var (
durationRegexp = regexp.MustCompile(`^(-?)P(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)D)?(?:T(.+))?$`)
durationTimeRegexp = regexp.MustCompile(`^(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$`)
)
// UnmarshalText implements the encoding.TextUnmarshaler interface.
func (d *Duration) UnmarshalText(text []byte) error {
if text == nil {
*d = 0
return nil
}
var (
out time.Duration
sign time.Duration = 1
)
match := durationRegexp.FindStringSubmatch(string(text))
if match == nil || strings.Join(match[2:6], "") == "" {
return fmt.Errorf("invalid duration (%s)", text)
}
if match[1] == "-" {
sign = -1
}
if match[2] != "" {
y, err := strconv.Atoi(match[2])
if err != nil {
return fmt.Errorf("invalid duration years (%s): %s", text, err)
}
out += time.Duration(y) * year
}
if match[3] != "" {
m, err := strconv.Atoi(match[3])
if err != nil {
return fmt.Errorf("invalid duration months (%s): %s", text, err)
}
out += time.Duration(m) * month
}
if match[4] != "" {
d, err := strconv.Atoi(match[4])
if err != nil {
return fmt.Errorf("invalid duration days (%s): %s", text, err)
}
out += time.Duration(d) * day
}
if match[5] != "" {
match := durationTimeRegexp.FindStringSubmatch(match[5])
if match == nil {
return fmt.Errorf("invalid duration (%s)", text)
}
if match[1] != "" {
h, err := strconv.Atoi(match[1])
if err != nil {
return fmt.Errorf("invalid duration hours (%s): %s", text, err)
}
out += time.Duration(h) * time.Hour
}
if match[2] != "" {
m, err := strconv.Atoi(match[2])
if err != nil {
return fmt.Errorf("invalid duration minutes (%s): %s", text, err)
}
out += time.Duration(m) * time.Minute
}
if match[3] != "" {
s, err := strconv.ParseFloat(match[3], 64)
if err != nil {
return fmt.Errorf("invalid duration seconds (%s): %s", text, err)
}
out += time.Duration(s * float64(time.Second))
}
}
*d = Duration(sign * out)
return nil
}
+31
View File
@@ -0,0 +1,31 @@
package saml
import (
"compress/flate"
"fmt"
"io"
)
const flateUncompressLimit = 10 * 1024 * 1024 // 10MB
func newSaferFlateReader(r io.Reader) io.ReadCloser {
return &saferFlateReader{r: flate.NewReader(r)}
}
type saferFlateReader struct {
r io.ReadCloser
count int
}
func (r *saferFlateReader) Read(p []byte) (n int, err error) {
if r.count+len(p) > flateUncompressLimit {
return 0, fmt.Errorf("flate: uncompress limit exceeded (%d bytes)", flateUncompressLimit)
}
n, err = r.r.Read(p)
r.count += n
return n, err
}
func (r *saferFlateReader) Close() error {
return r.r.Close()
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
// Package logger provides a logging interface.
package logger
import (
"log"
"os"
)
// Interface provides the minimal logging interface
type Interface interface {
// Printf prints to the logger using the format.
Printf(format string, v ...interface{})
// Print prints to the logger.
Print(v ...interface{})
// Println prints new line.
Println(v ...interface{})
// Fatal is equivalent to Print() followed by a call to os.Exit(1).
Fatal(v ...interface{})
// Fatalf is equivalent to Printf() followed by a call to os.Exit(1).
Fatalf(format string, v ...interface{})
// Fatalln is equivalent to Println() followed by a call to os.Exit(1).
Fatalln(v ...interface{})
// Panic is equivalent to Print() followed by a call to panic().
Panic(v ...interface{})
// Panicf is equivalent to Printf() followed by a call to panic().
Panicf(format string, v ...interface{})
// Panicln is equivalent to Println() followed by a call to panic().
Panicln(v ...interface{})
}
// DefaultLogger logs messages to os.Stdout
var DefaultLogger = log.New(os.Stdout, "", log.LstdFlags)
+410
View File
@@ -0,0 +1,410 @@
package saml
import (
"encoding/xml"
"fmt"
"net/url"
"time"
"github.com/beevik/etree"
)
// HTTPPostBinding is the official URN for the HTTP-POST binding (transport)
const HTTPPostBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
// HTTPRedirectBinding is the official URN for the HTTP-Redirect binding (transport)
const HTTPRedirectBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
// HTTPArtifactBinding is the official URN for the HTTP-Artifact binding (transport)
const HTTPArtifactBinding = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact"
// SOAPBinding is the official URN for the SOAP binding (transport)
const SOAPBinding = "urn:oasis:names:tc:SAML:2.0:bindings:SOAP"
// SOAPBindingV1 is the URN for the SOAP binding in SAML 1.0
const SOAPBindingV1 = "urn:oasis:names:tc:SAML:1.0:bindings:SOAP-binding"
// EntitiesDescriptor represents the SAML object of the same name.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.3.1
type EntitiesDescriptor struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntitiesDescriptor"`
ID *string `xml:",attr,omitempty"`
ValidUntil *time.Time `xml:"validUntil,attr,omitempty"`
CacheDuration *time.Duration `xml:"cacheDuration,attr,omitempty"`
Name *string `xml:",attr,omitempty"`
Signature *etree.Element
EntitiesDescriptors []EntitiesDescriptor `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntitiesDescriptor"`
EntityDescriptors []EntityDescriptor `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"`
}
// Metadata as been renamed to EntityDescriptor
//
// This change was made to be consistent with the rest of the API which uses names
// from the SAML specification for types.
//
// This is a tombstone to help you discover this fact. You should update references
// to saml.Metadata to be saml.EntityDescriptor.
var Metadata = struct{}{}
// EntityDescriptor represents the SAML EntityDescriptor object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.3.2
type EntityDescriptor struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata EntityDescriptor"`
EntityID string `xml:"entityID,attr"`
ID string `xml:",attr,omitempty"`
ValidUntil time.Time `xml:"validUntil,attr,omitempty"`
CacheDuration time.Duration `xml:"cacheDuration,attr,omitempty"`
Signature *etree.Element
RoleDescriptors []RoleDescriptor `xml:"RoleDescriptor"`
IDPSSODescriptors []IDPSSODescriptor `xml:"IDPSSODescriptor"`
SPSSODescriptors []SPSSODescriptor `xml:"SPSSODescriptor"`
AuthnAuthorityDescriptors []AuthnAuthorityDescriptor `xml:"AuthnAuthorityDescriptor"`
AttributeAuthorityDescriptors []AttributeAuthorityDescriptor `xml:"AttributeAuthorityDescriptor"`
PDPDescriptors []PDPDescriptor `xml:"PDPDescriptor"`
AffiliationDescriptor *AffiliationDescriptor
Organization *Organization
ContactPerson *ContactPerson
AdditionalMetadataLocations []string `xml:"AdditionalMetadataLocation"`
}
// MarshalXML implements xml.Marshaler
func (m EntityDescriptor) MarshalXML(e *xml.Encoder, _ xml.StartElement) error {
type Alias EntityDescriptor
aux := &struct {
ValidUntil RelaxedTime `xml:"validUntil,attr,omitempty"`
CacheDuration Duration `xml:"cacheDuration,attr,omitempty"`
*Alias
}{
ValidUntil: RelaxedTime(m.ValidUntil),
CacheDuration: Duration(m.CacheDuration),
Alias: (*Alias)(&m),
}
return e.Encode(aux)
}
// UnmarshalXML implements xml.Unmarshaler
func (m *EntityDescriptor) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type Alias EntityDescriptor
aux := &struct {
ValidUntil RelaxedTime `xml:"validUntil,attr,omitempty"`
CacheDuration Duration `xml:"cacheDuration,attr,omitempty"`
*Alias
}{
Alias: (*Alias)(m),
}
if err := d.DecodeElement(aux, &start); err != nil {
return err
}
m.ValidUntil = time.Time(aux.ValidUntil)
m.CacheDuration = time.Duration(aux.CacheDuration)
return nil
}
// Organization represents the SAML Organization object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.3.2.1
type Organization struct {
OrganizationNames []LocalizedName `xml:"OrganizationName"`
OrganizationDisplayNames []LocalizedName `xml:"OrganizationDisplayName"`
OrganizationURLs []LocalizedURI `xml:"OrganizationURL"`
}
// LocalizedName represents the SAML type localizedNameType.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.2.4
type LocalizedName struct {
Lang string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
Value string `xml:",chardata"`
}
// LocalizedURI represents the SAML type localizedURIType.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.2.5
type LocalizedURI struct {
Lang string `xml:"http://www.w3.org/XML/1998/namespace lang,attr"`
Value string `xml:",chardata"`
}
// ContactPerson represents the SAML element ContactPerson.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.3.2.2
type ContactPerson struct {
ContactType string `xml:"contactType,attr"`
Company string
GivenName string
SurName string
EmailAddresses []string `xml:"EmailAddress"`
TelephoneNumbers []string `xml:"TelephoneNumber"`
}
// RoleDescriptor represents the SAML element RoleDescriptor.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.1
type RoleDescriptor struct {
ID string `xml:",attr,omitempty"`
ValidUntil *time.Time `xml:"validUntil,attr,omitempty"`
CacheDuration time.Duration `xml:"cacheDuration,attr,omitempty"`
ProtocolSupportEnumeration string `xml:"protocolSupportEnumeration,attr"`
ErrorURL string `xml:"errorURL,attr,omitempty"`
Signature *etree.Element
KeyDescriptors []KeyDescriptor `xml:"KeyDescriptor,omitempty"`
Organization *Organization `xml:"Organization,omitempty"`
ContactPeople []ContactPerson `xml:"ContactPerson,omitempty"`
}
// KeyDescriptor represents the XMLSEC object of the same name
type KeyDescriptor struct {
Use string `xml:"use,attr"`
KeyInfo KeyInfo `xml:"http://www.w3.org/2000/09/xmldsig# KeyInfo"`
EncryptionMethods []EncryptionMethod `xml:"EncryptionMethod"`
}
// EncryptionMethod represents the XMLSEC object of the same name
type EncryptionMethod struct {
Algorithm string `xml:"Algorithm,attr"`
}
// KeyInfo represents the XMLSEC object of the same name
type KeyInfo struct {
XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# KeyInfo"`
X509Data X509Data `xml:"X509Data"`
}
// X509Data represents the XMLSEC object of the same name
type X509Data struct {
XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# X509Data"`
X509Certificates []X509Certificate `xml:"X509Certificate"`
}
// X509Certificate represents the XMLSEC object of the same name
type X509Certificate struct {
XMLName xml.Name `xml:"http://www.w3.org/2000/09/xmldsig# X509Certificate"`
Data string `xml:",chardata"`
}
// Endpoint represents the SAML EndpointType object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.2.2
type Endpoint struct {
Binding string `xml:"Binding,attr"`
Location string `xml:"Location,attr"`
ResponseLocation string `xml:"ResponseLocation,attr,omitempty"`
}
func checkEndpointLocation(binding string, location string) (string, error) {
// Within the SAML standard, the complex type EndpointType describes a
// SAML protocol binding endpoint at which a SAML entity can be sent
// protocol messages. In particular, the location of an endpoint type is
// defined as follows in the Metadata for the OASIS Security Assertion
// Markup Language (SAML) V2.0 - 2.2.2 Complex Type EndpointType:
//
// Location [Required] A required URI attribute that specifies the
// location of the endpoint. The allowable syntax of this URI depends
// on the protocol binding.
switch binding {
case HTTPPostBinding,
HTTPRedirectBinding,
HTTPArtifactBinding,
SOAPBinding,
SOAPBindingV1:
locationURL, err := url.Parse(location)
if err != nil {
return "", fmt.Errorf("invalid url %q: %w", location, err)
}
switch locationURL.Scheme {
case "http", "https":
// ok
default:
return "", fmt.Errorf("invalid url scheme %q for binding %q",
locationURL.Scheme, binding)
}
default:
// We don't know what form location should take, but the protocol
// requires that we validate its syntax.
//
// In practice, lots of metadata contains random bindings, for example
// "urn:mace:shibboleth:1.0:profiles:AuthnRequest" from our own test suite.
//
// We can't fail, but we also can't allow a location parameter whose syntax we
// cannot verify. The least-bad course of action here is to set location to
// and empty string, and hope the caller doesn't care need it.
location = ""
}
return location, nil
}
// UnmarshalXML implements xml.Unmarshaler
func (m *Endpoint) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type Alias Endpoint
aux := &struct {
*Alias
}{
Alias: (*Alias)(m),
}
if err := d.DecodeElement(aux, &start); err != nil {
return err
}
var err error
m.Location, err = checkEndpointLocation(m.Binding, m.Location)
if err != nil {
return err
}
if m.ResponseLocation != "" {
m.ResponseLocation, err = checkEndpointLocation(m.Binding, m.ResponseLocation)
if err != nil {
return err
}
}
return nil
}
// IndexedEndpoint represents the SAML IndexedEndpointType object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.2.3
type IndexedEndpoint struct {
Binding string `xml:"Binding,attr"`
Location string `xml:"Location,attr"`
ResponseLocation *string `xml:"ResponseLocation,attr,omitempty"`
Index int `xml:"index,attr"`
IsDefault *bool `xml:"isDefault,attr"`
}
// UnmarshalXML implements xml.Unmarshaler
func (m *IndexedEndpoint) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
type Alias IndexedEndpoint
aux := &struct {
*Alias
}{
Alias: (*Alias)(m),
}
if err := d.DecodeElement(aux, &start); err != nil {
return err
}
var err error
m.Location, err = checkEndpointLocation(m.Binding, m.Location)
if err != nil {
return err
}
if m.ResponseLocation != nil {
responseLocation, err := checkEndpointLocation(m.Binding, *m.ResponseLocation)
if err != nil {
return err
}
if responseLocation != "" {
m.ResponseLocation = &responseLocation
} else {
m.ResponseLocation = nil
}
}
return nil
}
// SSODescriptor represents the SAML complex type SSODescriptor
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.2
type SSODescriptor struct {
RoleDescriptor
ArtifactResolutionServices []IndexedEndpoint `xml:"ArtifactResolutionService"`
SingleLogoutServices []Endpoint `xml:"SingleLogoutService"`
ManageNameIDServices []Endpoint `xml:"ManageNameIDService"`
NameIDFormats []NameIDFormat `xml:"NameIDFormat"`
}
// IDPSSODescriptor represents the SAML IDPSSODescriptorType object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.3
type IDPSSODescriptor struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata IDPSSODescriptor"`
SSODescriptor
WantAuthnRequestsSigned *bool `xml:",attr"`
SingleSignOnServices []Endpoint `xml:"SingleSignOnService"`
ArtifactResolutionServices []Endpoint `xml:"ArtifactResolutionService"`
NameIDMappingServices []Endpoint `xml:"NameIDMappingService"`
AssertionIDRequestServices []Endpoint `xml:"AssertionIDRequestService"`
AttributeProfiles []string `xml:"AttributeProfile"`
Attributes []Attribute `xml:"Attribute"`
}
// SPSSODescriptor represents the SAML SPSSODescriptorType object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.2
type SPSSODescriptor struct {
XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:metadata SPSSODescriptor"`
SSODescriptor
AuthnRequestsSigned *bool `xml:",attr"`
WantAssertionsSigned *bool `xml:",attr"`
AssertionConsumerServices []IndexedEndpoint `xml:"AssertionConsumerService"`
AttributeConsumingServices []AttributeConsumingService `xml:"AttributeConsumingService"`
}
// AttributeConsumingService represents the SAML AttributeConsumingService object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.4.1
type AttributeConsumingService struct {
Index int `xml:"index,attr"`
IsDefault *bool `xml:"isDefault,attr"`
ServiceNames []LocalizedName `xml:"ServiceName"`
ServiceDescriptions []LocalizedName `xml:"ServiceDescription"`
RequestedAttributes []RequestedAttribute `xml:"RequestedAttribute"`
}
// RequestedAttribute represents the SAML RequestedAttribute object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.4.2
type RequestedAttribute struct {
Attribute
IsRequired *bool `xml:"isRequired,attr"`
}
// AuthnAuthorityDescriptor represents the SAML AuthnAuthorityDescriptor object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.5
type AuthnAuthorityDescriptor struct {
RoleDescriptor
AuthnQueryServices []Endpoint `xml:"AuthnQueryService"`
AssertionIDRequestServices []Endpoint `xml:"AssertionIDRequestService"`
NameIDFormats []NameIDFormat `xml:"NameIDFormat"`
}
// PDPDescriptor represents the SAML PDPDescriptor object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.6
type PDPDescriptor struct {
RoleDescriptor
AuthzServices []Endpoint `xml:"AuthzService"`
AssertionIDRequestServices []Endpoint `xml:"AssertionIDRequestService"`
NameIDFormats []NameIDFormat `xml:"NameIDFormat"`
}
// AttributeAuthorityDescriptor represents the SAML AttributeAuthorityDescriptor object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.4.7
type AttributeAuthorityDescriptor struct {
RoleDescriptor
AttributeServices []Endpoint `xml:"AttributeService"`
AssertionIDRequestServices []Endpoint `xml:"AssertionIDRequestService"`
NameIDFormats []NameIDFormat `xml:"NameIDFormat"`
AttributeProfiles []string `xml:"AttributeProfile"`
Attributes []Attribute `xml:"Attribute"`
}
// AffiliationDescriptor represents the SAML AffiliationDescriptor object.
//
// See http://docs.oasis-open.org/security/saml/v2.0/saml-metadata-2.0-os.pdf §2.5
type AffiliationDescriptor struct {
AffiliationOwnerID string `xml:"affiliationOwnerID,attr"`
ID string `xml:",attr"`
ValidUntil time.Time `xml:"validUntil,attr,omitempty"`
CacheDuration time.Duration `xml:"cacheDuration,attr"`
Signature *etree.Element
AffiliateMembers []string `xml:"AffiliateMember"`
KeyDescriptors []KeyDescriptor `xml:"KeyDescriptor"`
}
+177
View File
@@ -0,0 +1,177 @@
// Package saml contains a partial implementation of the SAML standard in golang.
// SAML is a standard for identity federation, i.e. either allowing a third party to authenticate your users or allowing third parties to rely on us to authenticate their users.
//
// # Introduction
//
// In SAML parlance an Identity Provider (IDP) is a service that knows how to authenticate users. A Service Provider (SP) is a service that delegates authentication to an IDP. If you are building a service where users log in with someone else's credentials, then you are a Service Provider. This package supports implementing both service providers and identity providers.
//
// The core package contains the implementation of SAML. The package samlsp provides helper middleware suitable for use in Service Provider applications. The package samlidp provides a rudimentary IDP service that is useful for testing or as a starting point for other integrations.
//
// # Breaking Changes
//
// Version 0.4.0 introduces a few breaking changes to the _samlsp_ package in order to make the package more extensible, and to clean up the interfaces a bit. The default behavior remains the same, but you can now provide interface implementations of _RequestTracker_ (which tracks pending requests), _Session_ (which handles maintaining a session) and _OnError_ which handles reporting errors.
//
// Public fields of _samlsp.Middleware_ have changed, so some usages may require adjustment. See [issue 231](https://github.com/crewjam/saml/issues/231) for details.
//
// The option to provide an IDP metadata URL has been deprecated. Instead, we recommend that you use the `FetchMetadata()` function, or fetch the metadata yourself and use the new `ParseMetadata()` function, and pass the metadata in _samlsp.Options.IDPMetadata_.
//
// Similarly, the _HTTPClient_ field is now deprecated because it was only used for fetching metdata, which is no longer directly implemented.
//
// The fields that manage how cookies are set are deprecated as well. To customize how cookies are managed, provide custom implementation of _RequestTracker_ and/or _Session_, perhaps by extending the default implementations.
//
// The deprecated fields have not been removed from the Options structure, but will be in future.
//
// In particular we have deprecated the following fields in
// _samlsp.Options_:
//
// - `Logger` - This was used to emit errors while validating, which is an anti-pattern.
// - `IDPMetadataURL` - Instead use `FetchMetadata()`
// - `HTTPClient` - Instead pass httpClient to FetchMetadata
// - `CookieMaxAge` - Instead assign a custom CookieRequestTracker or CookieSessionProvider
// - `CookieName` - Instead assign a custom CookieRequestTracker or CookieSessionProvider
// - `CookieDomain` - Instead assign a custom CookieRequestTracker or CookieSessionProvider
// - `CookieDomain` - Instead assign a custom CookieRequestTracker or CookieSessionProvider
//
// # Getting Started as a Service Provider
//
// Let us assume we have a simple web application to protect. We'll modify this application so it uses SAML to authenticate users.
//
// ```golang
// package main
//
// import (
//
// "fmt"
// "net/http"
//
// )
//
// func hello(w http.ResponseWriter, r *http.Request) {
// fmt.Fprintf(w, "Hello, World!")
// }
//
// func main() {
// app := http.HandlerFunc(hello)
// http.Handle("/hello", app)
// http.ListenAndServe(":8000", nil)
// }
//
// ```
//
// Each service provider must have an self-signed X.509 key pair established. You can generate your own with something like this:
//
// openssl req -x509 -newkey rsa:2048 -keyout myservice.key -out myservice.cert -days 365 -nodes -subj "/CN=myservice.example.com"
//
// We will use `samlsp.Middleware` to wrap the endpoint we want to protect. Middleware provides both an `http.Handler` to serve the SAML specific URLs and a set of wrappers to require the user to be logged in. We also provide the URL where the service provider can fetch the metadata from the IDP at startup. In our case, we'll use [samltest.id](https://samltest.id/), an identity provider designed for testing.
//
// ```golang
// package main
//
// import (
//
// "crypto/rsa"
// "crypto/tls"
// "crypto/x509"
// "fmt"
// "net/http"
// "net/url"
//
// "github.com/crewjam/saml/samlsp"
//
// )
//
// func hello(w http.ResponseWriter, r *http.Request) {
// fmt.Fprintf(w, "Hello, %s!", samlsp.Token(r.Context()).Attributes.Get("cn"))
// }
//
// func main() {
// keyPair, err := tls.LoadX509KeyPair("myservice.cert", "myservice.key")
// if err != nil {
// panic(err) // TODO handle error
// }
// keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0])
// if err != nil {
// panic(err) // TODO handle error
// }
//
// idpMetadataURL, err := url.Parse("https://samltest.id/saml/idp")
// if err != nil {
// panic(err) // TODO handle error
// }
//
// rootURL, err := url.Parse("http://localhost:8000")
// if err != nil {
// panic(err) // TODO handle error
// }
//
// samlSP, _ := samlsp.New(samlsp.Options{
// URL: *rootURL,
// Key: keyPair.PrivateKey.(*rsa.PrivateKey),
// Certificate: keyPair.Leaf,
// IDPMetadataURL: idpMetadataURL,
// })
// app := http.HandlerFunc(hello)
// http.Handle("/hello", samlSP.RequireAccount(app))
// http.Handle("/saml/", samlSP)
// http.ListenAndServe(":8000", nil)
// }
//
// ```
//
// Next we'll have to register our service provider with the identity provider to establish trust from the service provider to the IDP. For [samltest.id](https://samltest.id/), you can do something like:
//
// mdpath=saml-test-$USER-$HOST.xml
// curl localhost:8000/saml/metadata > $mdpath
//
// Navigate to https://samltest.id/upload.php and upload the file you fetched.
//
// Now you should be able to authenticate. The flow should look like this:
//
// 1. You browse to `localhost:8000/hello`
//
// 1. The middleware redirects you to `https://samltest.id/idp/profile/SAML2/Redirect/SSO`
//
// 1. samltest.id prompts you for a username and password.
//
// 1. samltest.id returns you an HTML document which contains an HTML form setup to POST to `localhost:8000/saml/acs`. The form is automatically submitted if you have javascript enabled.
//
// 1. The local service validates the response, issues a session cookie, and redirects you to the original URL, `localhost:8000/hello`.
//
// 1. This time when `localhost:8000/hello` is requested there is a valid session and so the main content is served.
//
// # Getting Started as an Identity Provider
//
// Please see `example/idp/` for a substantially complete example of how to use the library and helpers to be an identity provider.
//
// # Support
//
// The SAML standard is huge and complex with many dark corners and strange, unused features. This package implements the most commonly used subset of these features required to provide a single sign on experience. The package supports at least the subset of SAML known as [interoperable SAML](http://saml2int.org).
//
// This package supports the Web SSO profile. Message flows from the service provider to the IDP are supported using the HTTP Redirect binding and the HTTP POST binding. Message flows from the IDP to the service provider are supported via the HTTP POST binding.
//
// The package can produce signed SAML assertions, and can validate both signed and encrypted SAML assertions. It does not support signed or encrypted requests.
//
// # RelayState
//
// The _RelayState_ parameter allows you to pass user state information across the authentication flow. The most common use for this is to allow a user to request a deep link into your site, be redirected through the SAML login flow, and upon successful completion, be directed to the originally requested link, rather than the root.
//
// Unfortunately, _RelayState_ is less useful than it could be. Firstly, it is not authenticated, so anything you supply must be signed to avoid XSS or CSRF. Secondly, it is limited to 80 bytes in length, which precludes signing. (See section 3.6.3.1 of SAMLProfiles.)
//
// # References
//
// The SAML specification is a collection of PDFs (sadly):
//
// - [SAMLCore](http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf) defines data types.
//
// - [SAMLBindings](http://docs.oasis-open.org/security/saml/v2.0/saml-bindings-2.0-os.pdf) defines the details of the HTTP requests in play.
//
// - [SAMLProfiles](http://docs.oasis-open.org/security/saml/v2.0/saml-profiles-2.0-os.pdf) describes data flows.
//
// - [SAMLConformance](http://docs.oasis-open.org/security/saml/v2.0/saml-conformance-2.0-os.pdf) includes a support matrix for various parts of the protocol.
//
// [SAMLtest](https://samltest.id/) is a testing ground for SAML service and identity providers.
//
// # Security Issues
//
// Please do not report security issues in the issue tracker. Rather, please contact me directly at ross@kndr.org ([PGP Key `78B6038B3B9DFB88`](https://keybase.io/crewjam)).
package saml
+3
View File
@@ -0,0 +1,3 @@
package saml
//go:generate bash -c "(cat README.md | grep -E -v '^# SAML' | sed 's|^## ||g' | sed 's|\\*\\*||g' | sed 's|^|// |g'; echo 'package saml') > saml.go"
+25
View File
@@ -0,0 +1,25 @@
package samlsp
import (
"log"
"net/http"
"github.com/crewjam/saml"
)
// ErrorFunction is a callback that is invoked to return an error to the
// web user.
type ErrorFunction func(w http.ResponseWriter, r *http.Request, err error)
// DefaultOnError is the default ErrorFunction implementation. It prints
// an message via the standard log package and returns a simple text
// "Forbidden" message to the user.
func DefaultOnError(w http.ResponseWriter, _ *http.Request, err error) {
if parseErr, ok := err.(*saml.InvalidResponseError); ok {
log.Printf("WARNING: received invalid saml response: %s (now: %s) %s",
parseErr.Response, parseErr.Now, parseErr.PrivateErr)
} else {
log.Printf("ERROR: %s", err)
}
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
}
+81
View File
@@ -0,0 +1,81 @@
package samlsp
import (
"bytes"
"context"
"encoding/xml"
"errors"
"io"
"net/http"
"net/url"
"github.com/crewjam/httperr"
xrv "github.com/mattermost/xml-roundtrip-validator"
"github.com/crewjam/saml/logger"
"github.com/crewjam/saml"
)
// ParseMetadata parses arbitrary SAML IDP metadata.
//
// Note: this is needed because IDP metadata is sometimes wrapped in
// an <EntitiesDescriptor>, and sometimes the top level element is an
// <EntityDescriptor>.
func ParseMetadata(data []byte) (*saml.EntityDescriptor, error) {
entity := &saml.EntityDescriptor{}
if err := xrv.Validate(bytes.NewBuffer(data)); err != nil {
return nil, err
}
err := xml.Unmarshal(data, entity)
// this comparison is ugly, but it is how the error is generated in encoding/xml
if err != nil && err.Error() == "expected element type <EntityDescriptor> but have <EntitiesDescriptor>" {
entities := &saml.EntitiesDescriptor{}
if err := xml.Unmarshal(data, entities); err != nil {
return nil, err
}
for i, e := range entities.EntityDescriptors {
if len(e.IDPSSODescriptors) > 0 {
return &entities.EntityDescriptors[i], nil
}
}
return nil, errors.New("no entity found with IDPSSODescriptor")
}
if err != nil {
return nil, err
}
return entity, nil
}
// FetchMetadata returns metadata from an IDP metadata URL.
func FetchMetadata(ctx context.Context, httpClient *http.Client, metadataURL url.URL) (*saml.EntityDescriptor, error) {
req, err := http.NewRequest("GET", metadataURL.String(), nil)
if err != nil {
return nil, err
}
req = req.WithContext(ctx)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer func() {
if err := resp.Body.Close(); err != nil {
logger.DefaultLogger.Printf("Error while closing response body during fetch metadata: %v", err)
}
}()
if resp.StatusCode >= 400 {
return nil, httperr.Response(*resp)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return ParseMetadata(data)
}
+253
View File
@@ -0,0 +1,253 @@
package samlsp
import (
"bytes"
"encoding/xml"
"net/http"
"github.com/crewjam/saml"
)
// Middleware implements middleware than allows a web application
// to support SAML.
//
// It implements http.Handler so that it can provide the metadata and ACS endpoints,
// typically /saml/metadata and /saml/acs, respectively.
//
// It also provides middleware RequireAccount which redirects users to
// the auth process if they do not have session credentials.
//
// When redirecting the user through the SAML auth flow, the middleware assigns
// a temporary cookie with a random name beginning with "saml_". The value of
// the cookie is a signed JSON Web Token containing the original URL requested
// and the SAML request ID. The random part of the name corresponds to the
// RelayState parameter passed through the SAML flow.
//
// When validating the SAML response, the RelayState is used to look up the
// correct cookie, validate that the SAML request ID, and redirect the user
// back to their original URL.
//
// Sessions are established by issuing a JSON Web Token (JWT) as a session
// cookie once the SAML flow has succeeded. The JWT token contains the
// authenticated attributes from the SAML assertion.
//
// When the middleware receives a request with a valid session JWT it extracts
// the SAML attributes and modifies the http.Request object adding a Context
// object to the request context that contains attributes from the initial
// SAML assertion.
//
// When issuing JSON Web Tokens, a signing key is required. Because the
// SAML service provider already has a private key, we borrow that key
// to sign the JWTs as well.
type Middleware struct {
ServiceProvider saml.ServiceProvider
OnError func(w http.ResponseWriter, r *http.Request, err error)
Binding string // either saml.HTTPPostBinding or saml.HTTPRedirectBinding
ResponseBinding string // either saml.HTTPPostBinding or saml.HTTPArtifactBinding
RequestTracker RequestTracker
Session SessionProvider
}
// ServeHTTP implements http.Handler and serves the SAML-specific HTTP endpoints
// on the URIs specified by m.ServiceProvider.MetadataURL and
// m.ServiceProvider.AcsURL.
func (m *Middleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == m.ServiceProvider.MetadataURL.Path {
m.ServeMetadata(w, r)
return
}
if r.URL.Path == m.ServiceProvider.AcsURL.Path {
m.ServeACS(w, r)
return
}
http.NotFoundHandler().ServeHTTP(w, r)
}
// ServeMetadata handles requests for the SAML metadata endpoint.
func (m *Middleware) ServeMetadata(w http.ResponseWriter, _ *http.Request) {
buf, _ := xml.MarshalIndent(m.ServiceProvider.Metadata(), "", " ")
w.Header().Set("Content-Type", "application/samlmetadata+xml")
if _, err := w.Write(buf); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// ServeACS handles requests for the SAML ACS endpoint.
func (m *Middleware) ServeACS(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
m.OnError(w, r, err)
return
}
possibleRequestIDs := []string{}
if m.ServiceProvider.AllowIDPInitiated {
possibleRequestIDs = append(possibleRequestIDs, "")
}
trackedRequests := m.RequestTracker.GetTrackedRequests(r)
for _, tr := range trackedRequests {
possibleRequestIDs = append(possibleRequestIDs, tr.SAMLRequestID)
}
assertion, err := m.ServiceProvider.ParseResponse(r, possibleRequestIDs)
if err != nil {
m.OnError(w, r, err)
return
}
m.CreateSessionFromAssertion(w, r, assertion, m.ServiceProvider.DefaultRedirectURI)
}
// RequireAccount is HTTP middleware that requires that each request be
// associated with a valid session. If the request is not associated with a valid
// session, then rather than serve the request, the middleware redirects the user
// to start the SAML auth flow.
func (m *Middleware) RequireAccount(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session, err := m.Session.GetSession(r)
if session != nil {
r = r.WithContext(ContextWithSession(r.Context(), session))
handler.ServeHTTP(w, r)
return
}
if err == ErrNoSession {
m.HandleStartAuthFlow(w, r)
return
}
m.OnError(w, r, err)
})
}
// HandleStartAuthFlow is called to start the SAML authentication process.
func (m *Middleware) HandleStartAuthFlow(w http.ResponseWriter, r *http.Request) {
// If we try to redirect when the original request is the ACS URL we'll
// end up in a loop. This is a programming error, so we panic here. In
// general this means a 500 to the user, which is preferable to a
// redirect loop.
if r.URL.Path == m.ServiceProvider.AcsURL.Path {
panic("don't wrap Middleware with RequireAccount")
}
var binding, bindingLocation string
if m.Binding != "" {
binding = m.Binding
bindingLocation = m.ServiceProvider.GetSSOBindingLocation(binding)
} else {
binding = saml.HTTPRedirectBinding
bindingLocation = m.ServiceProvider.GetSSOBindingLocation(binding)
if bindingLocation == "" {
binding = saml.HTTPPostBinding
bindingLocation = m.ServiceProvider.GetSSOBindingLocation(binding)
}
}
authReq, err := m.ServiceProvider.MakeAuthenticationRequest(bindingLocation, binding, m.ResponseBinding)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// relayState is limited to 80 bytes but also must be integrity protected.
// this means that we cannot use a JWT because it is way to long. Instead
// we set a signed cookie that encodes the original URL which we'll check
// against the SAML response when we get it.
relayState, err := m.RequestTracker.TrackRequest(w, r, authReq.ID)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if binding == saml.HTTPRedirectBinding {
redirectURL, err := authReq.Redirect(relayState, &m.ServiceProvider)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Add("Location", redirectURL.String())
w.WriteHeader(http.StatusFound)
return
}
if binding == saml.HTTPPostBinding {
w.Header().Add("Content-Security-Policy", ""+
"default-src; "+
"script-src 'sha256-AjPdJSbZmeWHnEc5ykvJFay8FTWeTeRbs9dutfZ0HqE='; "+
"reflected-xss block; referrer no-referrer;")
w.Header().Add("Content-type", "text/html")
var buf bytes.Buffer
buf.WriteString(`<!DOCTYPE html><html><body>`)
buf.Write(authReq.Post(relayState))
buf.WriteString(`</body></html>`)
if _, err := w.Write(buf.Bytes()); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
return
}
panic("not reached")
}
// CreateSessionFromAssertion is invoked by ServeHTTP when we have a new, valid SAML assertion.
func (m *Middleware) CreateSessionFromAssertion(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion, redirectURI string) {
if trackedRequestIndex := r.Form.Get("RelayState"); trackedRequestIndex != "" {
trackedRequest, err := m.RequestTracker.GetTrackedRequest(r, trackedRequestIndex)
if err != nil {
if err == http.ErrNoCookie && m.ServiceProvider.AllowIDPInitiated {
if uri := r.Form.Get("RelayState"); uri != "" {
redirectURI = uri
}
} else {
m.OnError(w, r, err)
return
}
} else {
if err := m.RequestTracker.StopTrackingRequest(w, r, trackedRequestIndex); err != nil {
m.OnError(w, r, err)
return
}
redirectURI = trackedRequest.URI
}
}
if err := m.Session.CreateSession(w, r, assertion); err != nil {
m.OnError(w, r, err)
return
}
http.Redirect(w, r, redirectURI, http.StatusFound)
}
// RequireAttribute returns a middleware function that requires that the
// SAML attribute `name` be set to `value`. This can be used to require
// that a remote user be a member of a group. It relies on the Claims assigned
// to to the context in RequireAccount.
//
// For example:
//
// goji.Use(m.RequireAccount)
// goji.Use(RequireAttributeMiddleware("eduPersonAffiliation", "Staff"))
func RequireAttribute(name, value string) func(http.Handler) http.Handler {
return func(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if session := SessionFromContext(r.Context()); session != nil {
// this will panic if we have the wrong type of Session, and that is OK.
sessionWithAttributes := session.(SessionWithAttributes)
attributes := sessionWithAttributes.GetAttributes()
if values, ok := attributes[name]; ok {
for _, v := range values {
if v == value {
handler.ServeHTTP(w, r)
return
}
}
}
}
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
})
}
}
+154
View File
@@ -0,0 +1,154 @@
// Package samlsp provides helpers that can be used to protect web services using SAML.
package samlsp
import (
"crypto/rsa"
"crypto/x509"
"net/http"
"net/url"
dsig "github.com/russellhaering/goxmldsig"
"github.com/crewjam/saml"
)
// Options represents the parameters for creating a new middleware
type Options struct {
EntityID string
URL url.URL
Key *rsa.PrivateKey
Certificate *x509.Certificate
Intermediates []*x509.Certificate
HTTPClient *http.Client
AllowIDPInitiated bool
DefaultRedirectURI string
IDPMetadata *saml.EntityDescriptor
SignRequest bool
UseArtifactResponse bool
ForceAuthn bool // TODO(ross): this should be *bool
RequestedAuthnContext *saml.RequestedAuthnContext
CookieSameSite http.SameSite
CookieName string
RelayStateFunc func(w http.ResponseWriter, r *http.Request) string
LogoutBindings []string
}
// DefaultSessionCodec returns the default SessionCodec for the provided options,
// a JWTSessionCodec configured to issue signed tokens.
func DefaultSessionCodec(opts Options) JWTSessionCodec {
return JWTSessionCodec{
SigningMethod: defaultJWTSigningMethod,
Audience: opts.URL.String(),
Issuer: opts.URL.String(),
MaxAge: defaultSessionMaxAge,
Key: opts.Key,
}
}
// DefaultSessionProvider returns the default SessionProvider for the provided options,
// a CookieSessionProvider configured to store sessions in a cookie.
func DefaultSessionProvider(opts Options) CookieSessionProvider {
cookieName := opts.CookieName
if cookieName == "" {
cookieName = defaultSessionCookieName
}
return CookieSessionProvider{
Name: cookieName,
Domain: opts.URL.Host,
MaxAge: defaultSessionMaxAge,
HTTPOnly: true,
Secure: opts.URL.Scheme == "https",
SameSite: opts.CookieSameSite,
Codec: DefaultSessionCodec(opts),
}
}
// DefaultTrackedRequestCodec returns a new TrackedRequestCodec for the provided
// options, a JWTTrackedRequestCodec that uses a JWT to encode TrackedRequests.
func DefaultTrackedRequestCodec(opts Options) JWTTrackedRequestCodec {
return JWTTrackedRequestCodec{
SigningMethod: defaultJWTSigningMethod,
Audience: opts.URL.String(),
Issuer: opts.URL.String(),
MaxAge: saml.MaxIssueDelay,
Key: opts.Key,
}
}
// DefaultRequestTracker returns a new RequestTracker for the provided options,
// a CookieRequestTracker which uses cookies to track pending requests.
func DefaultRequestTracker(opts Options, serviceProvider *saml.ServiceProvider) CookieRequestTracker {
return CookieRequestTracker{
ServiceProvider: serviceProvider,
NamePrefix: "saml_",
Codec: DefaultTrackedRequestCodec(opts),
MaxAge: saml.MaxIssueDelay,
RelayStateFunc: opts.RelayStateFunc,
SameSite: opts.CookieSameSite,
}
}
// DefaultServiceProvider returns the default saml.ServiceProvider for the provided
// options.
func DefaultServiceProvider(opts Options) saml.ServiceProvider {
metadataURL := opts.URL.ResolveReference(&url.URL{Path: "saml/metadata"})
acsURL := opts.URL.ResolveReference(&url.URL{Path: "saml/acs"})
sloURL := opts.URL.ResolveReference(&url.URL{Path: "saml/slo"})
var forceAuthn *bool
if opts.ForceAuthn {
forceAuthn = &opts.ForceAuthn
}
signatureMethod := dsig.RSASHA1SignatureMethod
if !opts.SignRequest {
signatureMethod = ""
}
if opts.DefaultRedirectURI == "" {
opts.DefaultRedirectURI = "/"
}
if len(opts.LogoutBindings) == 0 {
opts.LogoutBindings = []string{saml.HTTPPostBinding}
}
return saml.ServiceProvider{
EntityID: opts.EntityID,
Key: opts.Key,
Certificate: opts.Certificate,
HTTPClient: opts.HTTPClient,
Intermediates: opts.Intermediates,
MetadataURL: *metadataURL,
AcsURL: *acsURL,
SloURL: *sloURL,
IDPMetadata: opts.IDPMetadata,
ForceAuthn: forceAuthn,
RequestedAuthnContext: opts.RequestedAuthnContext,
SignatureMethod: signatureMethod,
AllowIDPInitiated: opts.AllowIDPInitiated,
DefaultRedirectURI: opts.DefaultRedirectURI,
LogoutBindings: opts.LogoutBindings,
}
}
// New creates a new Middleware with the default providers for the
// given options.
//
// You can customize the behavior of the middleware in more detail by
// replacing and/or changing Session, RequestTracker, and ServiceProvider
// in the returned Middleware.
func New(opts Options) (*Middleware, error) {
m := &Middleware{
ServiceProvider: DefaultServiceProvider(opts),
Binding: "",
ResponseBinding: saml.HTTPPostBinding,
OnError: DefaultOnError,
Session: DefaultSessionProvider(opts),
}
m.RequestTracker = DefaultRequestTracker(opts, &m.ServiceProvider)
if opts.UseArtifactResponse {
m.ResponseBinding = saml.HTTPArtifactBinding
}
return m, nil
}
+46
View File
@@ -0,0 +1,46 @@
package samlsp
import (
"net/http"
)
// RequestTracker tracks pending authentication requests.
//
// There are two main reasons for this:
//
// 1. When the middleware initiates an authentication request it must track the original URL
// in order to redirect the user to the right place after the authentication completes.
//
// 2. After the authentication completes, we want to ensure that the user presenting the
// assertion is actually the one the request it, to mitigate request forgeries.
type RequestTracker interface {
// TrackRequest starts tracking the SAML request with the given ID. It returns an
// `index` that should be used as the RelayState in the SAMl request flow.
TrackRequest(w http.ResponseWriter, r *http.Request, samlRequestID string) (index string, err error)
// StopTrackingRequest stops tracking the SAML request given by index, which is a string
// previously returned from TrackRequest
StopTrackingRequest(w http.ResponseWriter, r *http.Request, index string) error
// GetTrackedRequests returns all the pending tracked requests
GetTrackedRequests(r *http.Request) []TrackedRequest
// GetTrackedRequest returns a pending tracked request.
GetTrackedRequest(r *http.Request, index string) (*TrackedRequest, error)
}
// TrackedRequest holds the data we store for each pending request.
type TrackedRequest struct {
Index string `json:"-"`
SAMLRequestID string `json:"id"`
URI string `json:"uri"`
}
// TrackedRequestCodec handles encoding and decoding of a TrackedRequest.
type TrackedRequestCodec interface {
// Encode returns an encoded string representing the TrackedRequest.
Encode(value TrackedRequest) (string, error)
// Decode returns a Tracked request from an encoded string.
Decode(signed string) (*TrackedRequest, error)
}
+111
View File
@@ -0,0 +1,111 @@
package samlsp
import (
"encoding/base64"
"fmt"
"net/http"
"strings"
"time"
"github.com/crewjam/saml"
)
var _ RequestTracker = CookieRequestTracker{}
// CookieRequestTracker tracks requests by setting a uniquely named
// cookie for each request.
type CookieRequestTracker struct {
ServiceProvider *saml.ServiceProvider
NamePrefix string
Codec TrackedRequestCodec
MaxAge time.Duration
RelayStateFunc func(w http.ResponseWriter, r *http.Request) string
SameSite http.SameSite
}
// TrackRequest starts tracking the SAML request with the given ID. It returns an
// `index` that should be used as the RelayState in the SAMl request flow.
func (t CookieRequestTracker) TrackRequest(w http.ResponseWriter, r *http.Request, samlRequestID string) (string, error) {
trackedRequest := TrackedRequest{
Index: base64.RawURLEncoding.EncodeToString(randomBytes(42)),
SAMLRequestID: samlRequestID,
URI: r.URL.String(),
}
if t.RelayStateFunc != nil {
relayState := t.RelayStateFunc(w, r)
if relayState != "" {
trackedRequest.Index = relayState
}
}
signedTrackedRequest, err := t.Codec.Encode(trackedRequest)
if err != nil {
return "", err
}
http.SetCookie(w, &http.Cookie{
Name: t.NamePrefix + trackedRequest.Index,
Value: signedTrackedRequest,
MaxAge: int(t.MaxAge.Seconds()),
HttpOnly: true,
SameSite: t.SameSite,
Secure: t.ServiceProvider.AcsURL.Scheme == "https",
Path: t.ServiceProvider.AcsURL.Path,
})
return trackedRequest.Index, nil
}
// StopTrackingRequest stops tracking the SAML request given by index, which is a string
// previously returned from TrackRequest
func (t CookieRequestTracker) StopTrackingRequest(w http.ResponseWriter, r *http.Request, index string) error {
cookie, err := r.Cookie(t.NamePrefix + index)
if err != nil {
return err
}
cookie.Value = ""
cookie.Domain = t.ServiceProvider.AcsURL.Hostname()
cookie.Expires = time.Unix(1, 0) // past time as close to epoch as possible, but not zero time.Time{}
http.SetCookie(w, cookie)
return nil
}
// GetTrackedRequests returns all the pending tracked requests
func (t CookieRequestTracker) GetTrackedRequests(r *http.Request) []TrackedRequest {
rv := []TrackedRequest{}
for _, cookie := range r.Cookies() {
if !strings.HasPrefix(cookie.Name, t.NamePrefix) {
continue
}
trackedRequest, err := t.Codec.Decode(cookie.Value)
if err != nil {
continue
}
index := strings.TrimPrefix(cookie.Name, t.NamePrefix)
if index != trackedRequest.Index {
continue
}
rv = append(rv, *trackedRequest)
}
return rv
}
// GetTrackedRequest returns a pending tracked request.
func (t CookieRequestTracker) GetTrackedRequest(r *http.Request, index string) (*TrackedRequest, error) {
cookie, err := r.Cookie(t.NamePrefix + index)
if err != nil {
return nil, err
}
trackedRequest, err := t.Codec.Decode(cookie.Value)
if err != nil {
return nil, err
}
if trackedRequest.Index != index {
return nil, fmt.Errorf("expected index %q, got %q", index, trackedRequest.Index)
}
return trackedRequest, nil
}
+75
View File
@@ -0,0 +1,75 @@
package samlsp
import (
"crypto/rsa"
"fmt"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/crewjam/saml"
)
var defaultJWTSigningMethod = jwt.SigningMethodRS256
// JWTTrackedRequestCodec encodes TrackedRequests as signed JWTs
type JWTTrackedRequestCodec struct {
SigningMethod jwt.SigningMethod
Audience string
Issuer string
MaxAge time.Duration
Key *rsa.PrivateKey
}
var _ TrackedRequestCodec = JWTTrackedRequestCodec{}
// JWTTrackedRequestClaims represents the JWT claims for a tracked request.
type JWTTrackedRequestClaims struct {
jwt.RegisteredClaims
TrackedRequest
SAMLAuthnRequest bool `json:"saml-authn-request"`
}
// Encode returns an encoded string representing the TrackedRequest.
func (s JWTTrackedRequestCodec) Encode(value TrackedRequest) (string, error) {
now := saml.TimeNow()
claims := JWTTrackedRequestClaims{
RegisteredClaims: jwt.RegisteredClaims{
Audience: jwt.ClaimStrings{s.Audience},
ExpiresAt: jwt.NewNumericDate(now.Add(s.MaxAge)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: s.Issuer,
NotBefore: jwt.NewNumericDate(now), // TODO(ross): correct for clock skew
Subject: value.Index,
},
TrackedRequest: value,
SAMLAuthnRequest: true,
}
token := jwt.NewWithClaims(s.SigningMethod, claims)
return token.SignedString(s.Key)
}
// Decode returns a Tracked request from an encoded string.
func (s JWTTrackedRequestCodec) Decode(signed string) (*TrackedRequest, error) {
parser := jwt.Parser{
ValidMethods: []string{s.SigningMethod.Alg()},
}
claims := JWTTrackedRequestClaims{}
_, err := parser.ParseWithClaims(signed, &claims, func(*jwt.Token) (interface{}, error) {
return s.Key.Public(), nil
})
if err != nil {
return nil, err
}
if !claims.VerifyAudience(s.Audience, true) {
return nil, fmt.Errorf("expected audience %q, got %q", s.Audience, claims.Audience)
}
if !claims.VerifyIssuer(s.Issuer, true) {
return nil, fmt.Errorf("expected issuer %q, got %q", s.Issuer, claims.Issuer)
}
if !claims.SAMLAuthnRequest {
return nil, fmt.Errorf("expected saml-authn-request")
}
claims.TrackedRequest.Index = claims.Subject
return &claims.TrackedRequest, nil
}
+89
View File
@@ -0,0 +1,89 @@
package samlsp
import (
"context"
"errors"
"net/http"
"github.com/crewjam/saml"
)
// Session is an interface implemented to contain a session.
type Session interface{}
// SessionWithAttributes is a session that can expose the
// attributes provided by the SAML identity provider.
type SessionWithAttributes interface {
Session
GetAttributes() Attributes
}
// ErrNoSession is the error returned when the remote user does not have a session
var ErrNoSession = errors.New("saml: session not present")
// SessionProvider is an interface implemented by types that can track
// the active session of a user. The default implementation is CookieSessionProvider
type SessionProvider interface {
// CreateSession is called when we have received a valid SAML assertion and
// should create a new session and modify the http response accordingly, e.g. by
// setting a cookie.
CreateSession(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) error
// DeleteSession is called to modify the response such that it removed the current
// session, e.g. by deleting a cookie.
DeleteSession(w http.ResponseWriter, r *http.Request) error
// GetSession returns the current Session associated with the request, or
// ErrNoSession if there is no valid session.
GetSession(r *http.Request) (Session, error)
}
// SessionCodec is an interface to convert SAML assertions to a
// Session. The default implementation uses JWTs, JWTSessionCodec.
type SessionCodec interface {
// New creates a Session from the SAML assertion.
New(assertion *saml.Assertion) (Session, error)
// Encode returns a serialized version of the Session.
//
// Note: When implementing this function, it is reasonable to expect that
// Session is of the exact type returned by New(), and panic if it is not.
Encode(s Session) (string, error)
// Decode parses the serialized session that may have been returned by Encode
// and returns a Session.
Decode(string) (Session, error)
}
type indexType int
const sessionIndex indexType = iota
// SessionFromContext returns the session associated with ctx, or nil
// if no session are associated
func SessionFromContext(ctx context.Context) Session {
v := ctx.Value(sessionIndex)
if v == nil {
return nil
}
return v.(Session)
}
// ContextWithSession returns a new context with session associated
func ContextWithSession(ctx context.Context, session Session) context.Context {
return context.WithValue(ctx, sessionIndex, session)
}
// AttributeFromContext is a convenience method that returns the named attribute
// from the session, if available.
func AttributeFromContext(ctx context.Context, name string) string {
s := SessionFromContext(ctx)
if s == nil {
return ""
}
sa, ok := s.(SessionWithAttributes)
if !ok {
return ""
}
return sa.GetAttributes().Get(name)
}
+99
View File
@@ -0,0 +1,99 @@
package samlsp
import (
"net"
"net/http"
"time"
"github.com/crewjam/saml"
)
const defaultSessionCookieName = "token"
var _ SessionProvider = CookieSessionProvider{}
// CookieSessionProvider is an implementation of SessionProvider that stores
// session tokens in an HTTP cookie.
type CookieSessionProvider struct {
Name string
Domain string
HTTPOnly bool
Secure bool
SameSite http.SameSite
MaxAge time.Duration
Codec SessionCodec
}
// CreateSession is called when we have received a valid SAML assertion and
// should create a new session and modify the http response accordingly, e.g. by
// setting a cookie.
func (c CookieSessionProvider) CreateSession(w http.ResponseWriter, r *http.Request, assertion *saml.Assertion) error {
// Cookies should not have the port attached to them so strip it off
if domain, _, err := net.SplitHostPort(c.Domain); err == nil {
c.Domain = domain
}
session, err := c.Codec.New(assertion)
if err != nil {
return err
}
value, err := c.Codec.Encode(session)
if err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: c.Name,
Domain: c.Domain,
Value: value,
MaxAge: int(c.MaxAge.Seconds()),
HttpOnly: c.HTTPOnly,
Secure: c.Secure || r.URL.Scheme == "https",
SameSite: c.SameSite,
Path: "/",
})
return nil
}
// DeleteSession is called to modify the response such that it removed the current
// session, e.g. by deleting a cookie.
func (c CookieSessionProvider) DeleteSession(w http.ResponseWriter, r *http.Request) error {
// Cookies should not have the port attached to them so strip it off
if domain, _, err := net.SplitHostPort(c.Domain); err == nil {
c.Domain = domain
}
cookie, err := r.Cookie(c.Name)
if err == http.ErrNoCookie {
return nil
}
if err != nil {
return err
}
cookie.Value = ""
cookie.Expires = time.Unix(1, 0) // past time as close to epoch as possible, but not zero time.Time{}
cookie.Path = "/"
cookie.Domain = c.Domain
http.SetCookie(w, cookie)
return nil
}
// GetSession returns the current Session associated with the request, or
// ErrNoSession if there is no valid session.
func (c CookieSessionProvider) GetSession(r *http.Request) (Session, error) {
cookie, err := r.Cookie(c.Name)
if err == http.ErrNoCookie {
return nil, ErrNoSession
} else if err != nil {
return nil, err
}
session, err := c.Codec.Decode(cookie.Value)
if err != nil {
return nil, ErrNoSession
}
return session, nil
}
+143
View File
@@ -0,0 +1,143 @@
package samlsp
import (
"crypto/rsa"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v4"
"github.com/crewjam/saml"
)
const (
defaultSessionMaxAge = time.Hour
claimNameSessionIndex = "SessionIndex"
)
// JWTSessionCodec implements SessionCoded to encode and decode Sessions from
// the corresponding JWT.
type JWTSessionCodec struct {
SigningMethod jwt.SigningMethod
Audience string
Issuer string
MaxAge time.Duration
Key *rsa.PrivateKey
}
var _ SessionCodec = JWTSessionCodec{}
// New creates a Session from the SAML assertion.
//
// The returned Session is a JWTSessionClaims.
func (c JWTSessionCodec) New(assertion *saml.Assertion) (Session, error) {
now := saml.TimeNow()
claims := JWTSessionClaims{}
claims.SAMLSession = true
claims.Audience = c.Audience
claims.Issuer = c.Issuer
claims.IssuedAt = now.Unix()
claims.ExpiresAt = now.Add(c.MaxAge).Unix()
claims.NotBefore = now.Unix()
if sub := assertion.Subject; sub != nil {
if nameID := sub.NameID; nameID != nil {
claims.Subject = nameID.Value
}
}
claims.Attributes = map[string][]string{}
for _, attributeStatement := range assertion.AttributeStatements {
for _, attr := range attributeStatement.Attributes {
claimName := attr.FriendlyName
if claimName == "" {
claimName = attr.Name
}
for _, value := range attr.Values {
claims.Attributes[claimName] = append(claims.Attributes[claimName], value.Value)
}
}
}
// add SessionIndex to claims Attributes
for _, authnStatement := range assertion.AuthnStatements {
claims.Attributes[claimNameSessionIndex] = append(claims.Attributes[claimNameSessionIndex],
authnStatement.SessionIndex)
}
return claims, nil
}
// Encode returns a serialized version of the Session.
//
// The provided session must be a JWTSessionClaims, otherwise this
// function will panic.
func (c JWTSessionCodec) Encode(s Session) (string, error) {
claims := s.(JWTSessionClaims) // this will panic if you pass the wrong kind of session
token := jwt.NewWithClaims(c.SigningMethod, claims)
signedString, err := token.SignedString(c.Key)
if err != nil {
return "", err
}
return signedString, nil
}
// Decode parses the serialized session that may have been returned by Encode
// and returns a Session.
func (c JWTSessionCodec) Decode(signed string) (Session, error) {
parser := jwt.Parser{
ValidMethods: []string{c.SigningMethod.Alg()},
}
claims := JWTSessionClaims{}
_, err := parser.ParseWithClaims(signed, &claims, func(*jwt.Token) (interface{}, error) {
return c.Key.Public(), nil
})
// TODO(ross): check for errors due to bad time and return ErrNoSession
if err != nil {
return nil, err
}
if !claims.VerifyAudience(c.Audience, true) {
return nil, fmt.Errorf("expected audience %q, got %q", c.Audience, claims.Audience)
}
if !claims.VerifyIssuer(c.Issuer, true) {
return nil, fmt.Errorf("expected issuer %q, got %q", c.Issuer, claims.Issuer)
}
if !claims.SAMLSession {
return nil, errors.New("expected saml-session")
}
return claims, nil
}
// JWTSessionClaims represents the JWT claims in the encoded session
type JWTSessionClaims struct {
jwt.StandardClaims
Attributes Attributes `json:"attr"`
SAMLSession bool `json:"saml-session"`
}
var _ Session = JWTSessionClaims{}
// GetAttributes implements SessionWithAttributes. It returns the SAMl attributes.
func (c JWTSessionClaims) GetAttributes() Attributes {
return c.Attributes
}
// Attributes is a map of attributes provided in the SAML assertion
type Attributes map[string][]string
// Get returns the first attribute named `key` or an empty string if
// no such attributes is present.
func (a Attributes) Get(key string) string {
if a == nil {
return ""
}
v := a[key]
if len(v) == 0 {
return ""
}
return v[0]
}
+16
View File
@@ -0,0 +1,16 @@
package samlsp
import (
"io"
"github.com/crewjam/saml"
)
func randomBytes(n int) []byte {
rv := make([]byte, n)
if _, err := io.ReadFull(saml.RandReader, rv); err != nil {
panic(err)
}
return rv
}
+1321
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+52
View File
@@ -0,0 +1,52 @@
package saml
import "time"
// RelaxedTime is a version of time.Time that supports the time format
// found in SAML documents.
type RelaxedTime time.Time
const timeFormat = "2006-01-02T15:04:05.999Z07:00"
// MarshalText implements encoding.TextMarshaler
func (m RelaxedTime) MarshalText() ([]byte, error) {
// According to section 1.2.2 of the OASIS SAML 1.1 spec, we can't trust
// other applications to handle time resolution finer than a millisecond.
//
// The time MUST be expressed in UTC.
return []byte(m.String()), nil
}
func (m RelaxedTime) String() string {
return time.Time(m).Round(time.Millisecond).UTC().Format(timeFormat)
}
// UnmarshalText implements encoding.TextUnmarshaler
func (m *RelaxedTime) UnmarshalText(text []byte) error {
if len(text) == 0 {
*m = RelaxedTime(time.Time{})
return nil
}
t, err1 := time.Parse(time.RFC3339, string(text))
if err1 == nil {
t = t.Round(time.Millisecond)
*m = RelaxedTime(t)
return nil
}
t, err2 := time.Parse(time.RFC3339Nano, string(text))
if err2 == nil {
t = t.Round(time.Millisecond)
*m = RelaxedTime(t)
return nil
}
t, err2 = time.Parse("2006-01-02T15:04:05.999999999", string(text))
if err2 == nil {
t = t.Round(time.Millisecond)
*m = RelaxedTime(t)
return nil
}
return err1
}
+32
View File
@@ -0,0 +1,32 @@
package saml
import (
"crypto/rand"
"io"
"time"
dsig "github.com/russellhaering/goxmldsig"
)
// TimeNow is a function that returns the current time. The default
// value is time.Now, but it can be replaced for testing.
var TimeNow = func() time.Time { return time.Now().UTC() }
// Clock is assigned to dsig validation and signing contexts if it is
// not nil, otherwise the default clock is used.
var Clock *dsig.Clock
// RandReader is the io.Reader that produces cryptographically random
// bytes when they are need by the library. The default value is
// rand.Reader, but it can be replaced for testing.
var RandReader = rand.Reader
//nolint:unparam // This always receives 20, but we want the option to do more or less if needed.
func randomBytes(n int) []byte {
rv := make([]byte, n)
if _, err := io.ReadFull(RandReader, rv); err != nil {
panic(err)
}
return rv
}
+187
View File
@@ -0,0 +1,187 @@
package xmlenc
import (
"crypto/aes"
"crypto/cipher"
"crypto/des" // nolint: gas
"encoding/base64"
"errors"
"fmt"
"github.com/beevik/etree"
)
// CBC implements Decrypter and Encrypter for block ciphers in CBC mode
type CBC struct {
keySize int
algorithm string
cipher func([]byte) (cipher.Block, error)
}
// KeySize returns the length of the key required.
func (e CBC) KeySize() int {
return e.keySize
}
// Algorithm returns the name of the algorithm, as will be found
// in an xenc:EncryptionMethod element.
func (e CBC) Algorithm() string {
return e.algorithm
}
// Encrypt encrypts plaintext with key, which should be a []byte of length KeySize().
// It returns an xenc:EncryptedData element.
func (e CBC) Encrypt(key interface{}, plaintext []byte, _ []byte) (*etree.Element, error) {
keyBuf, ok := key.([]byte)
if !ok {
return nil, ErrIncorrectKeyType("[]byte")
}
if len(keyBuf) != e.keySize {
return nil, ErrIncorrectKeyLength(e.keySize)
}
block, err := e.cipher(keyBuf)
if err != nil {
return nil, err
}
encryptedDataEl := etree.NewElement("xenc:EncryptedData")
encryptedDataEl.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
{
randBuf := make([]byte, 16)
if _, err := RandReader.Read(randBuf); err != nil {
return nil, err
}
encryptedDataEl.CreateAttr("Id", fmt.Sprintf("_%x", randBuf))
}
em := encryptedDataEl.CreateElement("xenc:EncryptionMethod")
em.CreateAttr("Algorithm", e.algorithm)
em.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
plaintext = appendPadding(plaintext, block.BlockSize())
iv := make([]byte, block.BlockSize())
if _, err := RandReader.Read(iv); err != nil {
return nil, err
}
mode := cipher.NewCBCEncrypter(block, iv)
ciphertext := make([]byte, len(plaintext))
mode.CryptBlocks(ciphertext, plaintext)
ciphertext = append(iv, ciphertext...)
cd := encryptedDataEl.CreateElement("xenc:CipherData")
cd.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
cd.CreateElement("xenc:CipherValue").SetText(base64.StdEncoding.EncodeToString(ciphertext))
return encryptedDataEl, nil
}
// Decrypt decrypts an encrypted element with key. If the ciphertext contains an
// EncryptedKey element, then the type of `key` is determined by the registered
// Decryptor for the EncryptedKey element. Otherwise, `key` must be a []byte of
// length KeySize().
func (e CBC) Decrypt(key interface{}, ciphertextEl *etree.Element) ([]byte, error) {
// If the key is encrypted, decrypt it.
if encryptedKeyEl := ciphertextEl.FindElement("./KeyInfo/EncryptedKey"); encryptedKeyEl != nil {
var err error
key, err = Decrypt(key, encryptedKeyEl)
if err != nil {
return nil, err
}
}
keyBuf, ok := key.([]byte)
if !ok {
return nil, ErrIncorrectKeyType("[]byte")
}
if len(keyBuf) != e.KeySize() {
return nil, ErrIncorrectKeyLength(e.KeySize())
}
block, err := e.cipher(keyBuf)
if err != nil {
return nil, err
}
ciphertext, err := getCiphertext(ciphertextEl)
if err != nil {
return nil, err
}
if len(ciphertext) < block.BlockSize() {
return nil, errors.New("ciphertext too short")
}
iv := ciphertext[:aes.BlockSize]
ciphertext = ciphertext[aes.BlockSize:]
mode := cipher.NewCBCDecrypter(block, iv)
plaintext := make([]byte, len(ciphertext))
mode.CryptBlocks(plaintext, ciphertext) // decrypt in place
plaintext, err = stripPadding(plaintext)
if err != nil {
return nil, err
}
return plaintext, nil
}
var (
// AES128CBC implements AES128-CBC symetric key mode for encryption and decryption
AES128CBC BlockCipher = CBC{
keySize: 16,
algorithm: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
cipher: aes.NewCipher,
}
// AES192CBC implements AES192-CBC symetric key mode for encryption and decryption
AES192CBC BlockCipher = CBC{
keySize: 24,
algorithm: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
cipher: aes.NewCipher,
}
// AES256CBC implements AES256-CBC symetric key mode for encryption and decryption
AES256CBC BlockCipher = CBC{
keySize: 32,
algorithm: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
cipher: aes.NewCipher,
}
// TripleDES implements 3DES in CBC mode for encryption and decryption
TripleDES BlockCipher = CBC{
keySize: 8,
algorithm: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
cipher: des.NewCipher,
}
)
func init() {
RegisterDecrypter(AES128CBC)
RegisterDecrypter(AES192CBC)
RegisterDecrypter(AES256CBC)
RegisterDecrypter(TripleDES)
}
func appendPadding(buf []byte, blockSize int) []byte {
paddingBytes := blockSize - (len(buf) % blockSize)
padding := make([]byte, paddingBytes)
padding[len(padding)-1] = byte(paddingBytes)
return append(buf, padding...)
}
func stripPadding(buf []byte) ([]byte, error) {
if len(buf) < 1 {
return nil, errors.New("buffer is too short for padding")
}
paddingBytes := int(buf[len(buf)-1])
if paddingBytes > len(buf)-1 {
return nil, errors.New("buffer is too short for padding")
}
if paddingBytes < 1 {
return nil, errors.New("padding must be at least one byte")
}
return buf[:len(buf)-paddingBytes], nil
}
+116
View File
@@ -0,0 +1,116 @@
package xmlenc
import (
// nolint: gas
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"strings"
"github.com/beevik/etree"
)
// ErrAlgorithmNotImplemented is returned when encryption used is not
// supported.
type ErrAlgorithmNotImplemented string
func (e ErrAlgorithmNotImplemented) Error() string {
return "algorithm is not implemented: " + string(e)
}
// ErrCannotFindRequiredElement is returned by Decrypt when a required
// element cannot be found.
type ErrCannotFindRequiredElement string
func (e ErrCannotFindRequiredElement) Error() string {
return "cannot find required element: " + string(e)
}
// ErrIncorrectTag is returned when Decrypt is passed an element which
// is neither an EncryptedType nor an EncryptedKey
var ErrIncorrectTag = fmt.Errorf("tag must be an EncryptedType or EncryptedKey")
// ErrIncorrectKeyLength is returned when the fixed length key is not
// of the required length.
type ErrIncorrectKeyLength int
func (e ErrIncorrectKeyLength) Error() string {
return fmt.Sprintf("expected key to be %d bytes", int(e))
}
// ErrIncorrectKeyType is returned when the key is not the correct type
type ErrIncorrectKeyType string
func (e ErrIncorrectKeyType) Error() string {
return fmt.Sprintf("expected key to be %s", string(e))
}
// Decrypt decrypts the encrypted data using the provided key. If the
// data are encrypted using AES or 3DEC, then the key should be a []byte.
// If the data are encrypted with PKCS1v15 or RSA-OAEP-MGF1P then key should
// be a *rsa.PrivateKey.
func Decrypt(key interface{}, ciphertextEl *etree.Element) ([]byte, error) {
encryptionMethodEl := ciphertextEl.FindElement("./EncryptionMethod")
if encryptionMethodEl == nil {
return nil, ErrCannotFindRequiredElement("EncryptionMethod")
}
algorithm := encryptionMethodEl.SelectAttrValue("Algorithm", "")
decrypter, ok := decrypters[algorithm]
if !ok {
return nil, ErrAlgorithmNotImplemented(algorithm)
}
return decrypter.Decrypt(key, ciphertextEl)
}
func getCiphertext(encryptedKey *etree.Element) ([]byte, error) {
ciphertextEl := encryptedKey.FindElement("./CipherData/CipherValue")
if ciphertextEl == nil {
return nil, fmt.Errorf("cannot find CipherData element containing a CipherValue element")
}
ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimSpace(ciphertextEl.Text()))
if err != nil {
return nil, err
}
return ciphertext, nil
}
func validateRSAKeyIfPresent(key interface{}, encryptedKey *etree.Element) (*rsa.PrivateKey, error) {
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("expected key to be a *rsa.PrivateKey")
}
// extract and verify that the public key matches the certificate
// this section is included to either let the service know up front
// if the key will work, or let the service provider know which key
// to use to decrypt the message. Either way, verification is not
// security-critical.
//nolint:revive,staticcheck // Keep the later empty branch so that we know to address this at a later date.
if el := encryptedKey.FindElement("./KeyInfo/X509Data/X509Certificate"); el != nil {
certPEMbuf := el.Text()
certPEMbuf = "-----BEGIN CERTIFICATE-----\n" + certPEMbuf + "\n-----END CERTIFICATE-----\n"
certPEM, _ := pem.Decode([]byte(certPEMbuf))
if certPEM == nil {
return nil, fmt.Errorf("invalid certificate")
}
cert, err := x509.ParseCertificate(certPEM.Bytes)
if err != nil {
return nil, err
}
pubKey, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("expected certificate to be an *rsa.PublicKey")
}
if rsaKey.N.Cmp(pubKey.N) != 0 || rsaKey.E != pubKey.E {
return nil, fmt.Errorf("certificate does not match provided key")
}
} else if el = encryptedKey.FindElement("./KeyInfo/X509Data/X509IssuerSerial"); el != nil {
// TODO: determine how to validate the issuer serial information
}
return rsaKey, nil
}
+57
View File
@@ -0,0 +1,57 @@
package xmlenc
import (
"crypto/sha1" //nolint:gosec // required for protocol support
"crypto/sha256"
"crypto/sha512"
"hash"
//nolint:staticcheck // We should support this for legacy reasons.
"golang.org/x/crypto/ripemd160"
)
type digestMethod struct {
algorithm string
hash func() hash.Hash
}
func (dm digestMethod) Algorithm() string {
return dm.algorithm
}
func (dm digestMethod) Hash() hash.Hash {
return dm.hash()
}
var (
// SHA1 implements the SHA-1 digest method (which is considered insecure)
SHA1 = digestMethod{
algorithm: "http://www.w3.org/2000/09/xmldsig#sha1",
hash: sha1.New,
}
// SHA256 implements the SHA-256 digest method
SHA256 = digestMethod{
algorithm: "http://www.w3.org/2000/09/xmldsig#sha256",
hash: sha256.New,
}
// SHA512 implements the SHA-512 digest method
SHA512 = digestMethod{
algorithm: "http://www.w3.org/2000/09/xmldsig#sha512",
hash: sha512.New,
}
// RIPEMD160 implements the RIPEMD160 digest method
RIPEMD160 = digestMethod{
algorithm: "http://www.w3.org/2000/09/xmldsig#ripemd160",
hash: ripemd160.New,
}
)
func init() {
RegisterDigestMethod(SHA1)
RegisterDigestMethod(SHA256)
RegisterDigestMethod(SHA512)
RegisterDigestMethod(RIPEMD160)
}
+49
View File
@@ -0,0 +1,49 @@
package xmlenc
import (
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"github.com/beevik/etree"
)
var testKey = func() *rsa.PrivateKey {
const keyStr = `-----BEGIN RSA PRIVATE KEY-----
MIICXQIBAAKBgQDkXTUsWzRVpUHjbDpWCfYDfXmQ/q4LkaioZoTpu4ut1Q3eQC5t
gD14agJhgT8yzeY5S/YNlwCyuVkjuFyoyTHFX2IOPpz7jnh4KnQ+B1IH9fY/+kmk
zHJgxSUDJsdUMPgGpKt5hnEn7ziXAWXLc2udFbnHwhi9TXXwRHGi9wZ4YwIDAQAB
AoGBALNTnlXeqRI4W61DZ+v4ln/XIIeD9xiOoWrcVrNU2zL+g41ryQmkEqFkXcpD
vGUg2xFTXTz+v0WZ1y39sIW6uKFRYUfaNsF6iVfGAyx1VWK/jgtPnCWDQy26Eby0
BqpbZRy1a6MLYVEG/5bvZE01CDV4XttpTrNX91WWcYGduJxBAkEA6ED1ZOqIzBpu
c2KAo+bWmroCH8+cSDk0gVq6bnRB+EEhRCmo/VgvndWLxfexdGmDIOAIisB06N5a
GzBSCaEY/QJBAPu2cNvuuBNLwrlxPCwOEpIHYT4gJq8UMtg6O6N+u++nYCGhK6uo
VCmrKY+UewyNIcsLZF0jsNI2qJjiU1vQxN8CQQDfQJnigMQwlfO3/Ga1po6Buu2R
0IpkroB3G1R8GkrTrR+iGv2zUdKrwHsUOC2fPlFrB4+OeMOomRw6aG9jjDStAkB1
ztiZhuvuVAoKIv5HnDqC0CNqIUAZtzlozDB3f+xT6SFr+/Plfn4Nlod4JMVGhZNo
ZaeOlBLBAEX+cAcVtOs/AkBicZOAPv84ABmFfyhXhYaAuacaJLq//jg+t+URUOg+
XZS9naRmawEQxOkZQVoMeKgvu05+V4MniFqdQBINIkr5
-----END RSA PRIVATE KEY-----`
b, _ := pem.Decode([]byte(keyStr))
k, err := x509.ParsePKCS1PrivateKey(b.Bytes)
if err != nil {
panic(err)
}
return k
}()
// Fuzz is the go-fuzz fuzzing function
func Fuzz(data []byte) int {
doc := etree.NewDocument()
if err := doc.ReadFromBytes(data); err != nil {
return 0
}
if doc.Root() == nil {
return 0
}
if _, err := Decrypt(testKey, doc.Root()); err != nil {
return 0
}
return 1
}
+143
View File
@@ -0,0 +1,143 @@
package xmlenc
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"github.com/beevik/etree"
)
// GCM implements Decrypter and Encrypter for block ciphers in struct mode
type GCM struct {
keySize int
algorithm string
cipher func([]byte) (cipher.Block, error)
}
// KeySize returns the length of the key required.
func (e GCM) KeySize() int {
return e.keySize
}
// Algorithm returns the name of the algorithm, as will be found
// in an xenc:EncryptionMethod element.
func (e GCM) Algorithm() string {
return e.algorithm
}
// Encrypt encrypts plaintext with key and nonce
func (e GCM) Encrypt(key interface{}, plaintext []byte, nonce []byte) (*etree.Element, error) {
keyBuf, ok := key.([]byte)
if !ok {
return nil, ErrIncorrectKeyType("[]byte")
}
if len(keyBuf) != e.keySize {
return nil, ErrIncorrectKeyLength(e.keySize)
}
block, err := e.cipher(keyBuf)
if err != nil {
return nil, err
}
encryptedDataEl := etree.NewElement("xenc:EncryptedData")
encryptedDataEl.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
{
randBuf := make([]byte, 16)
if _, err := RandReader.Read(randBuf); err != nil {
return nil, err
}
encryptedDataEl.CreateAttr("Id", fmt.Sprintf("_%x", randBuf))
}
em := encryptedDataEl.CreateElement("xenc:EncryptionMethod")
em.CreateAttr("Algorithm", e.algorithm)
em.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
plaintext = appendPadding(plaintext, block.BlockSize())
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
if nonce == nil {
// generate random nonce when it's nil
nonce := make([]byte, aesgcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
panic(err.Error())
}
}
ciphertext := make([]byte, len(plaintext))
text := aesgcm.Seal(nil, nonce, ciphertext, nil)
cd := encryptedDataEl.CreateElement("xenc:CipherData")
cd.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
cd.CreateElement("xenc:CipherValue").SetText(base64.StdEncoding.EncodeToString(text))
return encryptedDataEl, nil
}
// Decrypt decrypts an encrypted element with key. If the ciphertext contains an
// EncryptedKey element, then the type of `key` is determined by the registered
// Decryptor for the EncryptedKey element. Otherwise, `key` must be a []byte of
// length KeySize().
func (e GCM) Decrypt(key interface{}, ciphertextEl *etree.Element) ([]byte, error) {
if encryptedKeyEl := ciphertextEl.FindElement("./KeyInfo/EncryptedKey"); encryptedKeyEl != nil {
var err error
key, err = Decrypt(key, encryptedKeyEl)
if err != nil {
return nil, err
}
}
keyBuf, ok := key.([]byte)
if !ok {
return nil, ErrIncorrectKeyType("[]byte")
}
if len(keyBuf) != e.KeySize() {
return nil, ErrIncorrectKeyLength(e.KeySize())
}
block, err := e.cipher(keyBuf)
if err != nil {
return nil, err
}
aesgcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
ciphertext, err := getCiphertext(ciphertextEl)
if err != nil {
return nil, err
}
nonce := ciphertext[:aesgcm.NonceSize()]
text := ciphertext[aesgcm.NonceSize():]
plainText, err := aesgcm.Open(nil, nonce, text, nil)
if err != nil {
return nil, err
}
return plainText, nil
}
var (
// AES128GCM implements AES128-GCM mode for encryption and decryption
AES128GCM BlockCipher = GCM{
keySize: 16,
algorithm: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
cipher: aes.NewCipher,
}
)
func init() {
RegisterDecrypter(AES128GCM)
}
+162
View File
@@ -0,0 +1,162 @@
package xmlenc
import (
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"fmt"
"github.com/beevik/etree"
)
// RSA implements Encrypter and Decrypter using RSA public key encryption.
//
// Use function like OAEP(), or PKCS1v15() to get an instance of this type ready
// to use.
type RSA struct {
BlockCipher BlockCipher
DigestMethod DigestMethod // only for OAEP
algorithm string
keyEncrypter func(e RSA, pubKey *rsa.PublicKey, plaintext []byte) ([]byte, error)
keyDecrypter func(e RSA, privKey *rsa.PrivateKey, ciphertext []byte) ([]byte, error)
}
// Algorithm returns the name of the algorithm
func (e RSA) Algorithm() string {
return e.algorithm
}
// Encrypt implements encrypter. certificate must be a []byte containing the ASN.1 bytes
// of certificate containing an RSA public key.
func (e RSA) Encrypt(certificate interface{}, plaintext []byte, nonce []byte) (*etree.Element, error) {
cert, ok := certificate.(*x509.Certificate)
if !ok {
return nil, ErrIncorrectKeyType("*x.509 certificate")
}
pubKey, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return nil, ErrIncorrectKeyType("x.509 certificate with an RSA public key")
}
// generate a key
key := make([]byte, e.BlockCipher.KeySize())
if _, err := RandReader.Read(key); err != nil {
return nil, err
}
keyInfoEl := etree.NewElement("ds:KeyInfo")
keyInfoEl.CreateAttr("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#")
encryptedKey := keyInfoEl.CreateElement("xenc:EncryptedKey")
{
randBuf := make([]byte, 16)
if _, err := RandReader.Read(randBuf); err != nil {
return nil, err
}
encryptedKey.CreateAttr("Id", fmt.Sprintf("_%x", randBuf))
}
encryptedKey.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
encryptionMethodEl := encryptedKey.CreateElement("xenc:EncryptionMethod")
encryptionMethodEl.CreateAttr("Algorithm", e.algorithm)
encryptionMethodEl.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
if e.DigestMethod != nil {
dm := encryptionMethodEl.CreateElement("ds:DigestMethod")
dm.CreateAttr("Algorithm", e.DigestMethod.Algorithm())
dm.CreateAttr("xmlns:ds", "http://www.w3.org/2000/09/xmldsig#")
}
{
innerKeyInfoEl := encryptedKey.CreateElement("ds:KeyInfo")
x509data := innerKeyInfoEl.CreateElement("ds:X509Data")
x509data.CreateElement("ds:X509Certificate").SetText(
base64.StdEncoding.EncodeToString(cert.Raw),
)
}
buf, err := e.keyEncrypter(e, pubKey, key)
if err != nil {
return nil, err
}
cd := encryptedKey.CreateElement("xenc:CipherData")
cd.CreateAttr("xmlns:xenc", "http://www.w3.org/2001/04/xmlenc#")
cd.CreateElement("xenc:CipherValue").SetText(base64.StdEncoding.EncodeToString(buf))
encryptedDataEl, err := e.BlockCipher.Encrypt(key, plaintext, nonce)
if err != nil {
return nil, err
}
encryptedDataEl.InsertChildAt(encryptedDataEl.FindElement("./CipherData").Index(), keyInfoEl)
return encryptedDataEl, nil
}
// Decrypt implements Decryptor. `key` must be an *rsa.PrivateKey.
func (e RSA) Decrypt(key interface{}, ciphertextEl *etree.Element) ([]byte, error) {
rsaKey, err := validateRSAKeyIfPresent(key, ciphertextEl)
if err != nil {
return nil, err
}
ciphertext, err := getCiphertext(ciphertextEl)
if err != nil {
return nil, err
}
{
digestMethodEl := ciphertextEl.FindElement("./EncryptionMethod/DigestMethod")
if digestMethodEl == nil {
e.DigestMethod = SHA1
} else {
hashAlgorithmStr := digestMethodEl.SelectAttrValue("Algorithm", "")
digestMethod, ok := digestMethods[hashAlgorithmStr]
if !ok {
return nil, ErrAlgorithmNotImplemented(hashAlgorithmStr)
}
e.DigestMethod = digestMethod
}
}
return e.keyDecrypter(e, rsaKey, ciphertext)
}
// OAEP returns a version of RSA that implements RSA in OAEP-MGF1P mode. By default
// the block cipher used is AES-256 CBC and the digest method is SHA-256. You can
// specify other ciphers and digest methods by assigning to BlockCipher or
// DigestMethod.
func OAEP() RSA {
return RSA{
BlockCipher: AES256CBC,
DigestMethod: SHA256,
algorithm: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
keyEncrypter: func(e RSA, pubKey *rsa.PublicKey, plaintext []byte) ([]byte, error) {
return rsa.EncryptOAEP(e.DigestMethod.Hash(), RandReader, pubKey, plaintext, nil)
},
keyDecrypter: func(e RSA, privKey *rsa.PrivateKey, ciphertext []byte) ([]byte, error) {
return rsa.DecryptOAEP(e.DigestMethod.Hash(), RandReader, privKey, ciphertext, nil)
},
}
}
// PKCS1v15 returns a version of RSA that implements RSA in PKCS1v15 mode. By default
// the block cipher used is AES-256 CBC. The DigestMethod field is ignored because PKCS1v15
// does not use a digest function.
func PKCS1v15() RSA {
return RSA{
BlockCipher: AES256CBC,
DigestMethod: nil,
algorithm: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
keyEncrypter: func(e RSA, pubKey *rsa.PublicKey, plaintext []byte) ([]byte, error) {
return rsa.EncryptPKCS1v15(RandReader, pubKey, plaintext)
},
keyDecrypter: func(e RSA, privKey *rsa.PrivateKey, ciphertext []byte) ([]byte, error) {
return rsa.DecryptPKCS1v15(RandReader, privKey, ciphertext)
},
}
}
func init() {
RegisterDecrypter(OAEP())
RegisterDecrypter(PKCS1v15())
}
+62
View File
@@ -0,0 +1,62 @@
// Package xmlenc is a partial implementation of the xmlenc standard
// as described in https://www.w3.org/TR/2002/REC-xmlenc-core-20021210/Overview.html.
// The purpose of this implementation is to support encrypted SAML assertions.
package xmlenc
import (
"crypto/rand"
"hash"
"github.com/beevik/etree"
)
// RandReader is a thunk that allows test to replace the source of randomness used by
// this package. By default it is Reader from crypto/rand.
var RandReader = rand.Reader
// Encrypter is an interface that encrypts things. Given a plaintext it returns an
// XML EncryptedData or EncryptedKey element. The required type of `key` varies
// depending on the implementation.
type Encrypter interface {
Encrypt(key interface{}, plaintext []byte, nonce []byte) (*etree.Element, error)
}
// Decrypter is an interface that decrypts things. The Decrypt() method returns the
// plaintext version of the EncryptedData or EncryptedKey element passed.
//
// You probably don't have to use this interface directly, instead you may call
// Decrypt() and it will examine the element to determine which Decrypter to use.
type Decrypter interface {
Algorithm() string
Decrypt(key interface{}, ciphertextEl *etree.Element) ([]byte, error)
}
// DigestMethod represents a digest method such as SHA1, etc.
type DigestMethod interface {
Algorithm() string
Hash() hash.Hash
}
var (
decrypters = map[string]Decrypter{}
digestMethods = map[string]DigestMethod{}
)
// RegisterDecrypter registers the specified decrypter to that it can be
// used with Decrypt().
func RegisterDecrypter(d Decrypter) {
decrypters[d.Algorithm()] = d
}
// RegisterDigestMethod registers the specified digest method to that it can be
// used with Decrypt().
func RegisterDigestMethod(dm DigestMethod) {
digestMethods[dm.Algorithm()] = dm
}
// BlockCipher implements a cipher with a fixed size key like AES or 3DES.
type BlockCipher interface {
Encrypter
Decrypter
KeySize() int
}