Commit 8e4f07e3 authored by ale's avatar ale

First attempt at SAML IDP app (SSO-enabled)

parent dc15ac91
Pipeline #607 passed with stage
in 18 seconds
package saml
import (
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/crewjam/saml"
"github.com/crewjam/saml/logger"
"github.com/gorilla/mux"
yaml "gopkg.in/yaml.v2"
"git.autistici.org/id/go-sso/httpsso"
)
type ServiceProviderConfig struct {
}
func (p *ServiceProviderConfig) toEntity() *saml.EntityDescriptor {
return nil
}
type Config struct {
BaseURL string `yaml:"base_url"`
UsersFile string `yaml:"users_file"`
// SAML X509 credentials.
CertificateFile string `yaml:"certificate_file"`
PrivateKeyFile string `yaml:"private_key_file"`
// SSO configuration.
SessionAuthKey string `yaml:"session_auth_key"`
SessionEncKey string `yaml:"session_enc_key"`
SSOLoginServerURL string `yaml:"sso_server_url"`
SSOPublicKeyFile string `yaml:"sso_public_key_file"`
SSODomain string `yaml:"sso_domain"`
// Service provider config.
ServiceProviders map[string]*ServiceProviderConfig `yaml:"service_providers"`
}
// Sanity checks for the configuration.
func (c *Config) check() error {
switch len(c.SessionAuthKey) {
case 32, 64:
case 0:
return errors.New("session_auth_key is empty")
default:
return errors.New("session_auth_key must be a random string of 32 or 64 bytes")
}
switch len(c.SessionEncKey) {
case 16, 24, 32:
case 0:
return errors.New("session_enc_key is empty")
default:
return errors.New("session_enc_key must be a random string of 16, 24 or 32 bytes")
}
if c.SSOLoginServerURL == "" {
return errors.New("sso_server_url is empty")
}
if c.SSODomain == "" {
return errors.New("sso_domain is empty")
}
return nil
}
func (c *Config) GetServiceProvider(r *http.Request, serviceProviderID string) (*saml.EntityDescriptor, error) {
srv, ok := c.ServiceProviders[serviceProviderID]
if !ok {
return nil, os.ErrNotExist
}
return srv.toEntity(), nil
}
// Read users from a YAML-encoded file, in a format surprisingly
// compatible with git.autistici.org/id/auth/server.
//
// TODO: Make it retrieve the email addresses as extra data in the SSO
// token (this feature is currently unsupported by the SSO server,
// even though the auth-server provides the information).
type userInfo struct {
Name string `yaml:"name"`
Email string `yaml:"email"`
}
type userFileBackend struct {
users map[string]userInfo
}
func newUserFileBackend(path string) (*userFileBackend, error) {
data, err := ioutil.ReadFile(path)
if err != nil {
return nil, err
}
var userList []userInfo
if err := yaml.Unmarshal(data, &userList); err != nil {
return nil, err
}
users := make(map[string]userInfo)
for _, u := range userList {
users[u.Name] = u
}
return &userFileBackend{users}, nil
}
func (b *userFileBackend) GetSession(w http.ResponseWriter, r *http.Request, req *saml.IdpAuthnRequest) *saml.Session {
// The request should have the X-Authenticated-User header.
username := r.Header.Get("X-Authenticated-User")
if username == "" {
http.Error(w, "No user found", http.StatusInternalServerError)
return nil
}
user, ok := b.users[username]
if !ok {
http.Error(w, "User not found", http.StatusInternalServerError)
return nil
}
return &saml.Session{
ID: base64.StdEncoding.EncodeToString(randomBytes(32)),
CreateTime: saml.TimeNow(),
ExpireTime: saml.TimeNow().Add(sessionMaxAge),
Index: hex.EncodeToString(randomBytes(32)),
UserName: user.Name,
UserEmail: user.Email,
UserCommonName: user.Name,
UserGivenName: user.Name,
}
}
func NewSAMLIDP(config *Config) (http.Handler, error) {
if err := config.check(); err != nil {
return nil, err
}
cert, err := tls.LoadX509KeyPair(config.CertificateFile, config.PrivateKeyFile)
if err != nil {
return nil, err
}
pkey, err := ioutil.ReadFile(config.SSOPublicKeyFile)
if err != nil {
return nil, err
}
w, err := httpsso.NewSSOWrapper(config.SSOLoginServerURL, pkey, config.SSODomain, []byte(config.SessionAuthKey), []byte(config.SessionEncKey))
if err != nil {
return nil, err
}
baseURL, err := url.Parse(config.BaseURL)
if err != nil {
return nil, err
}
ssoURL := baseURL
ssoURL.Path += "/sso"
metadataURL := baseURL
metadataURL.Path += "/metadata"
svc := fmt.Sprintf("%s%s", baseURL.Host, baseURL.Path)
if !strings.HasSuffix(svc, "/") {
svc += "/"
}
users, err := newUserFileBackend(config.UsersFile)
if err != nil {
return nil, err
}
idp := &saml.IdentityProvider{
Key: cert.PrivateKey,
Certificate: cert.Leaf,
Logger: logger.DefaultLogger,
SSOURL: *ssoURL,
ServiceProviderProvider: config,
SessionProvider: users,
}
h := idp.Handler()
root := mux.NewRouter()
root.Handle(ssoURL.Path, w.Wrap(h, svc, nil))
root.Handle(metadataURL.Path, h)
return root, nil
}
func randomBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b
}
var sessionMaxAge = 300 * time.Second
Brett Vickers (beevik)
Felix Geisendörfer (felixge)
Kamil Kisiel (kisielk)
Graham King (grahamking)
Matt Smith (ma314smith)
Michal Jemala (michaljemala)
Nicolas Piganeau (npiganeau)
Chris Brown (ccbrown)
Copyright 2015 Brett Vickers. 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 COPYRIGHT HOLDER ``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 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.
[![Build Status](https://travis-ci.org/beevik/etree.svg?branch=master)](https://travis-ci.org/beevik/etree)
[![GoDoc](https://godoc.org/github.com/beevik/etree?status.svg)](https://godoc.org/github.com/beevik/etree)
etree
=====
The etree package is a lightweight, pure go package that expresses XML in
the form of an element tree. Its design was inspired by the Python
[ElementTree](http://docs.python.org/2/library/xml.etree.elementtree.html)
module. Some of the package's features include:
* Represents XML documents as trees of elements for easy traversal.
* Imports, serializes, modifies or creates XML documents from scratch.
* Writes and reads XML to/from files, byte slices, strings and io interfaces.
* Performs simple or complex searches with lightweight XPath-like query APIs.
* Auto-indents XML using spaces or tabs for better readability.
* Implemented in pure go; depends only on standard go libraries.
* Built on top of the go [encoding/xml](http://golang.org/pkg/encoding/xml)
package.
### Creating an XML document
The following example creates an XML document from scratch using the etree
package and outputs its indented contents to stdout.
```go
doc := etree.NewDocument()
doc.CreateProcInst("xml", `version="1.0" encoding="UTF-8"`)
doc.CreateProcInst("xml-stylesheet", `type="text/xsl" href="style.xsl"`)
people := doc.CreateElement("People")
people.CreateComment("These are all known people")
jon := people.CreateElement("Person")
jon.CreateAttr("name", "Jon")
sally := people.CreateElement("Person")
sally.CreateAttr("name", "Sally")
doc.Indent(2)
doc.WriteTo(os.Stdout)
```
Output:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="style.xsl"?>
<People>
<!--These are all known people-->
<Person name="Jon"/>
<Person name="Sally"/>
</People>
```
### Reading an XML file
Suppose you have a file on disk called `bookstore.xml` containing the
following data:
```xml
<bookstore xmlns:p="urn:schemas-books-com:prices">
<book category="COOKING">
<title lang="en">Everyday Italian</title>
<author>Giada De Laurentiis</author>
<year>2005</year>
<p:price>30.00</p:price>
</book>
<book category="CHILDREN">
<title lang="en">Harry Potter</title>
<author>J K. Rowling</author>
<year>2005</year>
<p:price>29.99</p:price>
</book>
<book category="WEB">
<title lang="en">XQuery Kick Start</title>
<author>James McGovern</author>
<author>Per Bothner</author>
<author>Kurt Cagle</author>
<author>James Linn</author>
<author>Vaidyanathan Nagarajan</author>
<year>2003</year>
<p:price>49.99</p:price>
</book>
<book category="WEB">
<title lang="en">Learning XML</title>
<author>Erik T. Ray</author>
<year>2003</year>
<p:price>39.95</p:price>
</book>
</bookstore>
```
This code reads the file's contents into an etree document.
```go
doc := etree.NewDocument()
if err := doc.ReadFromFile("bookstore.xml"); err != nil {
panic(err)
}
```
You can also read XML from a string, a byte slice, or an `io.Reader`.
### Processing elements and attributes
This example illustrates several ways to access elements and attributes using
etree selection queries.
```go
root := doc.SelectElement("bookstore")
fmt.Println("ROOT element:", root.Tag)
for _, book := range root.SelectElements("book") {
fmt.Println("CHILD element:", book.Tag)
if title := book.SelectElement("title"); title != nil {
lang := title.SelectAttrValue("lang", "unknown")
fmt.Printf(" TITLE: %s (%s)\n", title.Text(), lang)
}
for _, attr := range book.Attr {
fmt.Printf(" ATTR: %s=%s\n", attr.Key, attr.Value)
}
}
```
Output:
```
ROOT element: bookstore
CHILD element: book
TITLE: Everyday Italian (en)
ATTR: category=COOKING
CHILD element: book
TITLE: Harry Potter (en)
ATTR: category=CHILDREN
CHILD element: book
TITLE: XQuery Kick Start (en)
ATTR: category=WEB
CHILD element: book
TITLE: Learning XML (en)
ATTR: category=WEB
```
### Path queries
This example uses etree's path functions to select all book titles that fall
into the category of 'WEB'. The double-slash prefix in the path causes the
search for book elements to occur recursively; book elements may appear at any
level of the XML hierarchy.
```go
for _, t := range doc.FindElements("//book[@category='WEB']/title") {
fmt.Println("Title:", t.Text())
}
```
Output:
```
Title: XQuery Kick Start
Title: Learning XML
```
This example finds the first book element under the root bookstore element and
outputs the tag and text of each of its child elements.
```go
for _, e := range doc.FindElements("./bookstore/book[1]/*") {
fmt.Printf("%s: %s\n", e.Tag, e.Text())
}
```
Output:
```
title: Everyday Italian
author: Giada De Laurentiis
year: 2005
price: 30.00
```
This example finds all books with a price of 49.99 and outputs their titles.
```go
path := etree.MustCompilePath("./bookstore/book[p:price='49.99']/title")
for _, e := range doc.FindElementsPath(path) {
fmt.Println(e.Text())
}
```
Output:
```
XQuery Kick Start
```
Note that this example uses the FindElementsPath function, which takes as an
argument a pre-compiled path object. Use precompiled paths when you plan to
search with the same path more than once.
### Other features
These are just a few examples of the things the etree package can do. See the
[documentation](http://godoc.org/github.com/beevik/etree) for a complete
description of its capabilities.
### Contributing
This project accepts contributions. Just fork the repo and submit a pull
request!
This diff is collapsed.
// Copyright 2015 Brett Vickers.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package etree
import (
"io"
"strings"
)
// A simple stack
type stack struct {
data []interface{}
}
func (s *stack) empty() bool {
return len(s.data) == 0
}
func (s *stack) push(value interface{}) {
s.data = append(s.data, value)
}
func (s *stack) pop() interface{} {
value := s.data[len(s.data)-1]
s.data[len(s.data)-1] = nil
s.data = s.data[:len(s.data)-1]
return value
}
func (s *stack) peek() interface{} {
return s.data[len(s.data)-1]
}
// A fifo is a simple first-in-first-out queue.
type fifo struct {
data []interface{}
head, tail int
}
func (f *fifo) add(value interface{}) {
if f.len()+1 >= len(f.data) {
f.grow()
}
f.data[f.tail] = value
if f.tail++; f.tail == len(f.data) {
f.tail = 0
}
}
func (f *fifo) remove() interface{} {
value := f.data[f.head]
f.data[f.head] = nil
if f.head++; f.head == len(f.data) {
f.head = 0
}
return value
}
func (f *fifo) len() int {
if f.tail >= f.head {
return f.tail - f.head
}
return len(f.data) - f.head + f.tail
}
func (f *fifo) grow() {
c := len(f.data) * 2
if c == 0 {
c = 4
}
buf, count := make([]interface{}, c), f.len()
if f.tail >= f.head {
copy(buf[0:count], f.data[f.head:f.tail])
} else {
hindex := len(f.data) - f.head
copy(buf[0:hindex], f.data[f.head:])
copy(buf[hindex:count], f.data[:f.tail])
}
f.data, f.head, f.tail = buf, 0, count
}
// countReader implements a proxy reader that counts the number of
// bytes read from its encapsulated reader.
type countReader struct {
r io.Reader
bytes int64
}
func newCountReader(r io.Reader) *countReader {
return &countReader{r: r}
}
func (cr *countReader) Read(p []byte) (n int, err error) {
b, err := cr.r.Read(p)
cr.bytes += int64(b)
return b, err
}
// countWriter implements a proxy writer that counts the number of
// bytes written by its encapsulated writer.
type countWriter struct {
w io.Writer
bytes int64
}
func newCountWriter(w io.Writer) *countWriter {
return &countWriter{w: w}
}
func (cw *countWriter) Write(p []byte) (n int, err error) {
b, err := cw.w.Write(p)
cw.bytes += int64(b)
return b, err
}
// isWhitespace returns true if the byte slice contains only
// whitespace characters.
func isWhitespace(s string) bool {
for i := 0; i < len(s); i++ {
if c := s[i]; c != ' ' && c != '\t' && c != '\n' && c != '\r' {
return false
}
}
return true
}
// spaceMatch returns true if namespace a is the empty string
// or if namespace a equals namespace b.
func spaceMatch(a, b string) bool {
switch {
case a == "":
return true
default:
return a == b
}
}
// spaceDecompose breaks a namespace:tag identifier at the ':'
// and returns the two parts.
func spaceDecompose(str string) (space, key string) {
colon := strings.IndexByte(str, ':')
if colon == -1 {
return "", str
}
return str[:colon], str[colon+1:]
}
// Strings used by crIndent
const (
crsp = "\n "
crtab = "\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t"
)
// crIndent returns a carriage return followed by n copies of the
// first non-CR character in the source string.
func crIndent(n int, source string) string {
switch {
case n < 0:
return source[:1]
case n < len(source):
return source[:n+1]
default:
return source + strings.Repeat(source[1:2], n-len(source)+1)
}
}
// nextIndex returns the index of the next occurrence of sep in s,
// starting from offset. It returns -1 if the sep string is not found.
func nextIndex(s, sep string, offset int) int {
switch i := strings.Index(s[offset:], sep); i {
case -1:
return -1
default:
return offset + i
}
}
// isInteger returns true if the string s contains an integer.
func isInteger(s string) bool {