Commit 1f3d4317 authored by Geert-Johan Riemer's avatar Geert-Johan Riemer

Merge branch 'feature/524-outway-autorization-service'

parents e6d933a7 6494ce8f
Pipeline #57049400 failed with stages
in 32 minutes and 54 seconds
# Use go 1.x based on the latest alpine image.
FROM golang:1-alpine AS build
# Install build tools.
RUN apk add --update git gcc musl-dev
# Cache dependencies
COPY go.mod /go/src/go.nlx.io/nlx/go.mod
COPY go.sum /go/src/go.nlx.io/nlx/go.sum
ENV GO111MODULE on
WORKDIR /go/src/go.nlx.io/nlx
RUN go mod download
COPY auth-service /go/src/go.nlx.io/nlx/auth-service
WORKDIR /go/src/go.nlx.io/nlx/auth-service
RUN go build -o dist/bin/auth-service ./cmd/auth-service
FROM alpine:latest
COPY --from=build /go/src/go.nlx.io/nlx/auth-service/dist/bin/auth-service /usr/local/bin/auth-service
COPY --from=build /go/src/go.nlx.io/nlx/auth-service/users.csv /users.csv
CMD ["/usr/local/bin/auth-service"]
package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"io"
"io/ioutil"
"log"
"net/http"
"strings"
flags "github.com/jessevdk/go-flags"
)
type authRequest struct {
Headers http.Header `json:"headers"`
Organization string `json:"organization"`
Service string `json:"service"`
}
type authResponse struct {
Authorized bool `json:"authorized"`
Headers http.Header `json:"headers"`
Reason string `json:"reason,omitempty"`
}
var options struct {
ListenAddress string `long:"listen-address" env:"LISTEN_ADDRESS" default:"0.0.0.0:443" description:"Adress for the api to listen on. Read https://golang.org/pkg/net/#Dial for possible tcp address specs."`
CVSFile string `long:"csv-file" env:"CSV_FILE" description:"absolute path to csv file to expose" required:"true"`
CertFile string `long:"tls-cert" env:"TLS_CERT" description:"Absolute or relative path to the Organization cert .pem"`
KeyFile string `long:"tls-key" env:"TLS_KEY" description:"Absolute or relative path to the Organization key .pem"`
}
type user struct {
ID string `json:"userID"`
Token string `json:"username"`
}
var users = make(map[string]*user)
func main() {
args, err := flags.Parse(&options)
if err != nil {
if et, ok := err.(*flags.Error); ok {
if et.Type == flags.ErrHelp {
return
}
}
log.Fatalf("error parsing flags: %v", err)
}
if len(args) > 0 {
log.Fatalf("unexpected arguments: %v", args)
}
users, err = loadCSVFile(options.CVSFile)
if err != nil {
log.Fatalf("error loading CVS file: %s", err)
}
http.HandleFunc("/auth", authenticateHandler)
log.Printf("starting http server on %s", options.ListenAddress)
log.Fatal(http.ListenAndServeTLS(options.ListenAddress, options.CertFile, options.KeyFile, nil))
}
func loadCSVFile(filePath string) (map[string]*user, error) {
data, err := ioutil.ReadFile(options.CVSFile)
if err != nil {
return nil, err
}
u := make(map[string]*user)
reader := csv.NewReader(bytes.NewBuffer(data))
for {
line, err := reader.Read()
if err == io.EOF {
break
} else if err != nil {
return nil, err
}
user := &user{
ID: line[0],
Token: line[1],
}
u[user.Token] = user
}
return u, nil
}
func authenticateHandler(w http.ResponseWriter, r *http.Request) {
authRequest := &authRequest{}
defer r.Body.Close()
err := json.NewDecoder(r.Body).Decode(authRequest)
if err != nil {
log.Printf("error decoding request %s", err)
http.Error(w, "error decoding request", http.StatusBadRequest)
return
}
log.Printf("Received auth request organization '%s' service '%s' ", authRequest.Organization, authRequest.Service)
token := parseToken(authRequest.Headers)
_, exists := users[token]
if !exists {
log.Println("user not found")
json.NewEncoder(w).Encode(&authResponse{
Authorized: false,
Reason: "invalid credentials",
})
return
}
json.NewEncoder(w).Encode(&authResponse{
Authorized: true,
})
}
// Parses token from the Proxy-Authorization header. The header should be in format <type> <credentials>
func parseToken(h http.Header) string {
authString := h.Get("Proxy-Authorization")
if len(authString) == 0 {
log.Println("empty authorization header")
return ""
}
authValues := strings.Split(authString, " ")
if len(authValues) != 2 {
log.Println("invalid authorization header")
return ""
}
authType := authValues[0]
authCredentails := authValues[1]
// In this example implementation we only support the Bearer authorization type.
switch authType {
case "Bearer":
return authCredentails
default:
return ""
}
}
007,8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
008,8bb0cf6eb9b17d0f7d22b456f121257dc1254e1f01665370476383ea776df414
009,
# Use go 1.x based on the latest alpine image.
FROM golang:1.12-alpine AS build
FROM golang:1-alpine AS build
# Install build tools.
RUN apk add --update git gcc musl-dev
......
{{ if .Values.outway.authService }}
{{ with set . "component" "auth-service" }}
apiVersion: extensions/v1beta1
kind: Deployment
metadata: {{ include "nlx-organization.common.metadata" . | nindent 2 }}
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
release: {{ .Release.Name }}
app: {{ .Chart.Name }}
component: {{.component}}
template:
metadata:
labels: {{ include "nlx-organization.common.metadata-labels" . | nindent 8 }}
spec:
volumes:
- name: certs
emptyDir: {}
initContainers:
- name: auth-certs
image: {{.Values.caCfsslUnsafeImage}}
imagePullPolicy: {{.Values.imagePullPolicy}}
volumeMounts:
- name: certs
mountPath: /certs
command: ["/bin/ash"]
args:
- "-c"
- |-
cd /certs &&
/ca/generate-cert.sh {{.component}} {{.Values.organizationName}} {{.Values.caAddress}}
containers:
- name: auth-service
image: {{.Values.authServiceImage}}
imagePullPolicy: {{.Values.imagePullPolicy}}
env:
- name: CSV_FILE
value: /users.csv
- name: TLS_CERT
value: "/certs/{{.component}}.pem"
- name: TLS_KEY
value: "/certs/{{.component}}-key.pem"
volumeMounts:
- name: certs
mountPath: /certs
restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata: {{ include "nlx-organization.common.metadata" . | nindent 2 }}
spec:
selector: {{ include "nlx-organization.common.metadata-labels" . | nindent 4 }}
ports:
- name: handler
protocol: TCP
port: 8443
targetPort: 443
cluserIP: None
{{ end }}
{{ end }}
......@@ -55,6 +55,14 @@ spec:
value: postgresql://{{.Values.dbUsernameTxlogWriter}}:{{.Values.dbPasswordTxlogWriter}}@{{.Values.dbHost}}/{{.Values.dbDatabaseTxlog}}?sslmode=disable&connect_timeout=10
- name: DIRECTORY_INSPECTION_ADDRESS
value: "{{.Values.directoryInspectionHostname}}:443"
{{ if .Values.outway.authService}}
- name: AUTHORIZATION_SERVICE_ADDRESS
value: {{.Values.outway.authorizationServiceURL}}
{{end}}
{{if .Values.outway.authorizationRootCA}}
- name: AUTHORIZATION_ROOT_CA
value: {{.Values.outway.authorizationRootCA}}
{{end}}
volumeMounts:
- name: certs
mountPath: /certs
......
......@@ -26,6 +26,10 @@ inway:
irma-api-url = "https://irma-api.acc.voorbeeld-haarlem.nl"
outway:
enable: true
authService: true
authorizationServiceURL: https://auth-service:8443
authorizationRootCA: /certs/nlx_root.pem
demoService:
enable: false
demoApplication:
......
......@@ -28,6 +28,10 @@ inway:
irma-api-url = "https://irma-api.demo.voorbeeld-haarlem.nl"
outway:
enable: true
authService: true
authorizationServiceURL: https://auth-service:8443
authorizationRootCA: /certs/nlx_root.pem
demoService:
enable: false
demoApplication:
......
......@@ -28,6 +28,7 @@ inway:
irma-api-url = "https://irma-api.demo.voorbeeld-rdw.nl"
outway:
enable: false
authService: false
demoService:
enable: true
genericServiceImage: nlxio/rdw-mock-service:latest
......
......@@ -63,6 +63,7 @@ inway:
ca-cert-path = "/ca-certs/basisregistratie.pem"
outway:
enable: false
authService: false
demoService:
enable: true
genericServiceImage: nlxio/brp-mock-service-https:latest
......
......@@ -18,6 +18,7 @@ caAddress: ca-cfssl-unsafe.nlx-dev-directory
directoryRegistrationHostname: directory-registration-api.dev.nlx.minikube
directoryInspectionHostname: directory-inspection-api.dev.nlx.minikube
inway:
enable: true
config: |
......@@ -29,6 +30,10 @@ inway:
irma-api-url = "http://irma-api.dev.haarlem.minikube:30080"
outway:
enable: true
authService: true
authorizationServiceURL: https://auth-service:8443
authorizationRootCA: /certs/nlx_root.pem
demoService:
enable: false
demoApplication:
......@@ -64,3 +69,4 @@ externalIP: ""
inwayHostnames:
- inway.dev.brp.minikube
- inway.dev.rdw.minikube
- inway.dev.haarlem.minikube
......@@ -29,6 +29,7 @@ inway:
irma-api-url = "http://irma-api.dev.rdw.minikube:30080"
outway:
enable: false
authService: false
demoService:
enable: true
genericServiceImage: nlxio/rdw-mock-service:latest
......
......@@ -26,6 +26,10 @@ inway:
irma-api-url = "https://irma-api.test.voorbeeld-haarlem.nl"
outway:
enable: true
authService: true
authorizationServiceURL: https://auth-service:8443
authorizationRootCA: /certs/nlx_root.pem
demoService:
enable: false
demoApplication:
......
# Use go 1.x based on the latest alpine image.
FROM golang:1.12-alpine AS build
FROM golang:1-alpine AS build
# Install build tools.
RUN apk add --update git gcc musl-dev
......
# Use go 1.x based on the latest alpine image.
FROM golang:1.12-alpine AS build
FROM golang:1-alpine AS build
# Install build tools.
RUN apk add --update git gcc musl-dev
......
......@@ -88,7 +88,7 @@ func main() {
for serviceName, serviceDetails := range serviceConfig.Services {
logger.Info("loaded service from service-config.toml", zap.String("service-name", serviceName))
logger.Debug("service configuration details", zap.String("service-name", serviceName), zap.String("endpoint-url", serviceDetails.EndpointURL),
zap.String("root-ca-path", serviceDetails.CACertPath), zap.String("authorizatio-model", serviceDetails.AuthorizationModel),
zap.String("root-ca-path", serviceDetails.CACertPath), zap.String("authorization-model", serviceDetails.AuthorizationModel),
zap.String("irma-api-url", serviceDetails.IrmaAPIURL), zap.String("insight-api-url", serviceDetails.InsightAPIURL),
zap.String("api-spec-url", serviceDetails.APISpecificationDocumentURL), zap.Bool("internal", serviceDetails.Internal),
zap.String("public-support-contact", serviceDetails.PublicSupportContact), zap.String("tech-support-contact", serviceDetails.TechSupportContact))
......
......@@ -142,6 +142,14 @@ func (h *HTTPServiceEndpoint) createRecordData(requestPath string, header http.H
if dataElements := header.Get("X-NLX-Request-Data-Elements"); dataElements != "" {
recordData["doelbinding-data-elements"] = dataElements
}
if userData := header.Get("X-NLX-Requester-User"); userData != "" {
recordData["doelbinding-user"] = userData
}
if claims := header.Get("X-NLX-Requester-Claims"); claims != "" {
recordData["doelbinding-claims"] = claims
}
recordData["request-path"] = requestPath
return recordData
......
# Use go 1.x based on the latest alpine image.
FROM golang:1.12-alpine AS build
FROM golang:1-alpine AS build
# Install build tools.
RUN apk add --update git gcc musl-dev
......
package outway
import (
"bytes"
"crypto/x509"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
)
type authRequest struct {
Headers http.Header `json:"headers"`
Organization string `json:"organization"`
Service string `json:"service"`
}
type authResponse struct {
Authorized bool `json:"authorized"`
Reason string `json:"reason,omitempty"`
}
type authSettings struct {
serviceURL string
ca *x509.CertPool
}
func (o *Outway) authorizeRequest(h http.Header, d *destination) (*authResponse, error) {
req, err := http.NewRequest(http.MethodPost, o.authorizationSettings.serviceURL, nil)
if err != nil {
return nil, err
}
authRequest := &authRequest{
Headers: h,
Organization: d.Organization,
Service: d.Service,
}
body, err := json.Marshal(authRequest)
if err != nil {
return nil, err
}
req.Body = ioutil.NopCloser(bytes.NewBuffer(body))
resp, err := o.authorizationClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("authorization service return non 200 status code. status code: %d", resp.StatusCode)
}
authResponse := &authResponse{}
err = json.NewDecoder(resp.Body).Decode(authResponse)
if err != nil {
return nil, err
}
return authResponse, nil
}
func (o *Outway) stripHeaders(r *http.Request, receiverOrganization string) {
if o.organizationName != receiverOrganization {
r.Header.Del("X-NLX-Requester-User")
r.Header.Del("X-NLX-Requester-Claims")
r.Header.Del("X-NLX-Request-Subject-Identifier")
r.Header.Del("X-NLX-Request-Application-Id")
r.Header.Del("X-NLX-Request-User-Id")
r.Header.Del("X-NLX-Request-Data-Subject")
}
r.Header.Del("Proxy-Authorization")
}
package outway
import (
"encoding/json"
"fmt"
"hash/crc64"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/golang/mock/gomock"
"github.com/sony/sonyflake"
"github.com/stretchr/testify/assert"
"go.uber.org/zap"
"go.nlx.io/nlx/common/transactionlog"
mock "go.nlx.io/nlx/outway/mock"
)
func TestStripHeaders(t *testing.T) {
o := &Outway{
organizationName: "org",
logger: zap.NewNop(),
}
headers := []string{
"X-NLX-Requester-User",
"X-NLX-Requester-Claims",
"X-NLX-Request-Subject-Identifier",
"X-NLX-Request-Application-Id",
"X-NLX-Request-User-Id",
"X-NLX-Request-Data-Subject",
}
r := &http.Request{
Header: http.Header{},
}
for _, header := range headers {
r.Header.Add(header, header)
}
o.stripHeaders(r, "org")
for _, header := range headers {
assert.Equal(t, header, r.Header.Get(header))
}
o.stripHeaders(r, "differentOrg")
for _, header := range headers {
assert.Equal(t, "", r.Header.Get(header))
}
r.Header.Add("Proxy-Authorization", "Proxy-Authorization")
o.stripHeaders(r, "org")
assert.Equal(t, "", r.Header.Get("Proxy-Authorization"))
}
func TestAuthListen(t *testing.T) {
logger := zap.NewNop()
// Createa a outway with a mock service
outway := &Outway{
organizationName: "org",
services: make(map[string]HTTPService),
logger: logger,
requestFlake: sonyflake.NewSonyflake(sonyflake.Settings{}),
ecmaTable: crc64.MakeTable(crc64.ECMA),
txlogger: transactionlog.NewDiscardTransactionLogger(),
}
// Setup mock httpservice
ctrl := gomock.NewController(t)
mockService := mock.NewMockHTTPService(ctrl)
defer ctrl.Finish()
mockService.EXPECT().ProxyHTTPRequest(gomock.Any(), gomock.Any()).Do(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mockAuthServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authRequest := &authRequest{}
err := json.NewDecoder(r.Body).Decode(authRequest)
assert.Nil(t, err)
authResponse := &authResponse{}
if user := authRequest.Headers.Get("Authorization-Proxy"); user == "Bearer token" {
authResponse.Authorized = true
json.NewEncoder(w).Encode(authResponse)
return
}
authResponse.Authorized = false
authResponse.Reason = "invalid user"
json.NewEncoder(w).Encode(authResponse)
}))
defer mockAuthServer.Close()
outway.services["mockorg.mockservice"] = mockService
outway.authorizationSettings = &authSettings{
serviceURL: mockAuthServer.URL,
}
outway.authorizationClient = http.Client{}
// Setup mock http server with the outway as http handler
mockServer := httptest.NewServer(outway)
defer mockServer.Close()
// Test http responses
tests := []struct {
url string
setAuthorizationHeader bool
statusCode int
errorMessage string
}{
{fmt.Sprintf("%s/mockorg/mockservice/", mockServer.URL), false, http.StatusUnauthorized, "nlx outway: authorization failed. reason: invalid user\n"},
{fmt.Sprintf("%s/mockorg/mockservice/", mockServer.URL), true, http.StatusOK, ""},
}
client := http.Client{}
for _, test := range tests {
req, err := http.NewRequest("GET", test.url, nil)
assert.Nil(t, err)
if test.setAuthorizationHeader {
req.Header.Add("Authorization-Proxy", "Bearer token")
}
resp, err := client.Do(req)
assert.Nil(t, err)
assert.Equal(t, test.statusCode, resp.StatusCode)
bytes, err := ioutil.ReadAll(resp.Body)
assert.Nil(t, err)
assert.Equal(t, test.errorMessage, string(bytes))
}
}
......@@ -29,6 +29,9 @@ var options struct {
DisableLogdb bool `long:"disable-logdb" env:"DISABLE_LOGDB" description:"Disable logdb connections"`
PostgresDSN string `long:"postgres-dsn" env:"POSTGRES_DSN" default:"postgres://postgres:postgres@postgres/nlx_logdb?sslmode=disable" description:"DSN for the postgres driver. See https://godoc.org/github.com/lib/pq#hdr-Connection_String_Parameters."`
AuthorizationServiceAddress string `long:"authorization-service-address" env:"AUTHORIZATION_SERVICE_ADDRESS" description:"Address of the authorization service. If set calls will go through the authorization service before being send to the inway"`
AuthorizationCA string `long:"authorization-root-ca" env:"AUTHORIZATION_ROOT_CA" description:"absolute path to root CA used to verify auth service certifcate"`
logoptions.LogOptions
orgtls.TLSOptions
}
......@@ -79,7 +82,7 @@ func main() {
}
// Create new outway and provide it with a hardcoded service.
ow, err := outway.NewOutway(process, logger, logDB, options.TLSOptions, options.DirectoryInspectionAddress)
ow, err := outway.NewOutway(process, logger, logDB, options.TLSOptions, options.DirectoryInspectionAddress, options.AuthorizationServiceAddress, options.AuthorizationCA)
if err != nil {
logger.Fatal("failed to setup outway", zap.Error(err))
}
......
......@@ -5,8 +5,11 @@ package outway
import (
"context"
"crypto/tls"
"encoding/binary"
"fmt"
"hash/crc64"
"net"
"net/http"
"strconv"
"strings"
......@@ -66,6 +69,21 @@ func (o *Outway) ListenAndServeTLS(process *process.Process, address string, cer
return nil
}
func createHTTPTransport(tlsConfig *tls.Config) *http.Transport {
return &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
DualStack: true,
}).DialContext,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
TLSClientConfig: tlsConfig,
}
}
// ServeHTTP handles requests from the organization to the outway, it selects the correct service backend and lets it handle the request further.
func (o *Outway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
logger := o.logger.With(
......@@ -73,75 +91,65 @@ func (o *Outway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
zap.String("request-remote-address", r.RemoteAddr),
)
urlparts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/"), "/", 3)
if len(urlparts) != 3 {
destination, err := parseURLPath(r.URL.Path)
if err != nil {
logger.Error("error parsing URL", zap.Error(err))
http.Error(w, "nlx outway: invalid path in url", http.StatusBadRequest)
logger.Warn("received request with invalid path")
return
}
destOrganizationName := urlparts[0]
destServiceName := urlparts[1]
requestPath := "/" + urlparts[2] // retain original path
r.URL.Path = requestPath
o.servicesLock.RLock()
service := o.services[destOrganizationName+"."+destServiceName]
o.servicesLock.RUnlock()
// Authorize request with plugged authorization service if authorization settings are set.
if o.authorizationSettings != nil {
authResponse, err := o.authorizeRequest(r.Header, destination)
if err != nil {
logger.Error("error authorizing request", zap.Error(err))
http.Error(w, "nlx outway: error authorizing request", http.StatusInternalServerError)
return
}
o.logger.Info("authorization result", zap.Bool("authorized", authResponse.Authorized), zap.String("reason", authResponse.Reason))
if !authResponse.Authorized {
http.Error(w, fmt.Sprintf("nlx outway: authorization failed. reason: %s", authResponse.Reason), http.StatusUnauthorized)
return
}
}
r.URL.Path = destination.Path
recordData := createRecordData(r.Header, destination.Path)
service := o.getService(destination.Organization, destination.Service)
if service == nil {
http.Error(w, "nlx outway: unknown service", http.StatusBadRequest)
logger.Warn("received request for unknown service")
return
}
var recordData = make(map[string]interface{})
recordData["request-path"] = requestPath
logrecordIDFlake, err := o.requestFlake.NextID()
if err != nil {
logger.Error("could not get new request ID", zap.Error(err))