Merge branch 'develop'

Replace the internal authentication with an external oauth2 provider
This commit is contained in:
Paul 2018-02-10 16:56:39 +01:00
commit ea14e74065
35 changed files with 1484 additions and 421 deletions

4
.gitignore vendored
View file

@ -1,3 +1,7 @@
*_vfsdata.go *_vfsdata.go
certman certman
db.sqlite3 db.sqlite3
*.crt
*.key
clients.json
.env

View file

@ -45,9 +45,9 @@ compile:
# build binaries -- list of supported plattforms is here: # build binaries -- list of supported plattforms is here:
# https://stackoverflow.com/a/20728862 # https://stackoverflow.com/a/20728862
- GOOS=linux GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman - GOOS=linux GOARCH=amd64 go build -tags "netgo" -o $CI_PROJECT_DIR/certman
- GOOS=linux GOARCH=arm GOARM=6 go build -o $CI_PROJECT_DIR/certman.arm - GOOS=linux GOARCH=arm GOARM=6 go build -tags "netgo" -o $CI_PROJECT_DIR/certman.arm
- GOOS=windows GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman.exe - GOOS=windows GOARCH=amd64 go build -tags "netgo" -o $CI_PROJECT_DIR/certman.exe
artifacts: artifacts:
expire_in: "8 hrs" expire_in: "8 hrs"
paths: paths:
@ -65,7 +65,7 @@ minify:
name: znly/upx:latest name: znly/upx:latest
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
script: script:
- upx --best --brute $CI_PROJECT_DIR/certman certman.arm certman.exe - upx --best --brute $CI_PROJECT_DIR/certman $CI_PROJECT_DIR/certman.arm $CI_PROJECT_DIR/certman.exe
artifacts: artifacts:
paths: paths:
- certman - certman
@ -73,3 +73,18 @@ minify:
- certman.exe - certman.exe
only: only:
- tags - tags
build_image:
stage: release
tags:
- dind
image: "docker:latest"
services:
- docker:dind
script:
- cd $CI_PROJECT_DIR
- docker login -u gitlab-ci-token -p $CI_JOB_TOKEN $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME#v} .
- docker push $CI_REGISTRY_IMAGE:${CI_COMMIT_REF_NAME#v}
# only:
# - tags

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM golang:1.9
WORKDIR /go/src/git.klink.asia/paul/certman
ADD . .
RUN \
go get -tags="dev" -v git.klink.asia/paul/certman && \
go get github.com/shurcooL/vfsgen/cmd/vfsgendev && \
go generate git.klink.asia/paul/certman/assets && \
go build -tags="netgo"
FROM scratch
ENV \
APP_KEY="" \
OAUTH2_CLIENT_ID="" \
OAUTH2_CLIENT_SECRET="" \
OAUTH2_AUTH_URL="https://gitlab.example.com/oauth/authorize" \
OAUTH2_TOKEN_URL="https://gitlab.example.com/oauth/token" \
OAUTH2_REDIRECT_URL="https://certman.example.com/login/oauth2/redirect" \
USER_ENDPOINT="https://gitlab.example.com/api/v4/user" \
APP_KEY=""
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=0 /go/src/git.klink.asia/paul/certman/certman /
ENTRYPOINT ["/certman"]

38
README.md Normal file
View file

@ -0,0 +1,38 @@
# Certman
Certman is a simple certificate manager web service for OpenVPN.
## Installation
### Binary
There are prebuilt binary files for this application. They are statically
linked and have no additional dependencies. Supported plattforms are:
* Windows (XP and up)
* Linux (2.6.16 and up)
* Linux ARM (for raspberry pi, 3.0 and up)
Simply download them from the "artifacts" section of this project.
### Docker
A prebuilt docker image (10MB) is available:
```bash
docker pull docker.klink.asia/paul/certman
```
### From Source-Docker
You can easily build your own docker image from source
```bash
docker build -t docker.klink.asia/paul/certman .
```
## Configuration
Certman assumes the root certificates of the VPN CA are located in the same
directory as the binary, If that is not the case you need to copy over the
`ca.crt` and `ca.key` files before you are able to generate certificates
with this tool.
Additionally, the project is configured by the following environment
variables:
* `OAUTH2_CLIENT_ID` the Client ID, assigned during client registration
* `OAUTH2_CLIENT_SECRET` the Client secret, assigned during client registration
* `OAUTH2_AUTH_URL` the URL to the "/authorize" endpoint of the identity provider
* `OAUTH2_TOKEN_URL` the URL to the "/token" endpoint of the identity provider
* `OAUTH2_REDIRECT_URL` the redirect URL used by the app, usually the hostname suffixed by "/login/oauth2/redirect"
* `USER_ENDPOINT` the URL to the Identity provider user endpoint, for gitlab this is "/api/v4/user". The "username" attribute of the returned JSON will used for authentication.
* `APP_KEY` random ASCII string, 32 characters in length. Used for cookie generation.
* `APP_LISTEN` port and ip to listen on, e.g. `:8000` or `127.0.0.1:3000`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
height="119.99999"
width="540"
xml:space="preserve"
viewBox="0 0 540 119.99999"
y="0px"
x="0px"
id="Ebene_1"
version="1.1"><metadata
id="metadata22"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title>ONEOFF</dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs20" /><style
id="style2"
type="text/css">
.st0{fill:#143C78;}
</style><g
transform="matrix(4,0,0,4,-365.2,-639.99996)"
id="XMLID_14_" /><title
id="title5">ONEOFF</title><desc
id="desc7">Created with Sketch.</desc><g
transform="matrix(1.1233618,0,0,1.0968921,-102.56293,-88.409503)"
id="XMLID_2_"><path
style="fill:#f9f9f9"
d="m 492.9,95.3 h -44 c -4.8,0 -8.7,3.9 -8.7,8.7 v 61.9 c 0,4.8 3.9,8.6 8.7,8.6 4.8,0 8.7,-3.8 8.7,-8.6 v -22.5 h 30.2 c 4.4,0 7.9,-3.5 7.9,-7.9 0,-4.4 -3.5,-7.9 -7.9,-7.9 H 457.6 V 111 h 35.2 c 4.4,0 7.9,-3.5 7.9,-7.9 0,-4.3 -3.5,-7.8 -7.8,-7.8 z"
class="st0"
id="XMLID_13_" /><path
style="fill:#f9f9f9"
d="m 564.1,111 c 4.4,0 7.9,-3.5 7.9,-7.9 0,-4.4 -3.5,-7.9 -7.9,-7.9 h -44 c -4.8,0 -8.4,3.9 -8.4,8.7 v 61.9 c 0,4.8 3.7,8.6 8.5,8.6 4.8,0 8.5,-3.8 8.5,-8.6 V 143.3 H 559 c 4.4,0 7.9,-3.5 7.9,-7.9 0,-4.4 -3.5,-7.9 -7.9,-7.9 H 528.7 V 111 Z"
class="st0"
id="XMLID_12_" /><path
style="fill:#f9f9f9"
d="m 386.6,94 c -21.6,0 -38.1,14.6 -41.3,33.6 h -14.9 c -3.9,0.1 -7,3.3 -7,7.2 0,3.9 3.1,7.1 7,7.2 h 14.9 c 3.3,18.9 19.5,33.1 41.1,33.1 24.2,0 41.9,-18.3 41.9,-40.6 0,-22.4 -17.5,-40.5 -41.7,-40.5 z m 23.4,40.8 c 0,13.5 -9.6,24.5 -23.5,24.5 -11.2,0 -19.8,-7.3 -22.7,-17.2 h 22.7 c 4,0 7.3,-3.1 7.3,-7.2 0,-4 -3.3,-7.3 -7.3,-7.3 h -22.9 c 2.6,-10.2 11.2,-17.7 22.7,-17.7 13.9,0 23.7,11.2 23.7,24.7 z"
class="st0"
id="XMLID_9_" /><path
style="fill:#f9f9f9"
d="m 342.5,80.7 -10.8,16 c -11.7,0 -49.1,-0.1 -49.3,-0.1 -3.8,0.2 -6.8,3.4 -6.8,7.2 0,4 3.2,7.2 7.2,7.2 0.1,0 29.7,0 39.1,0 l -11.1,16.4 c -8.6,0 -28.1,0 -28.2,0 h -0.2 v -0.1 c -3.8,0.1 -7,3.3 -7,7.2 0,3.9 3.1,7.1 7,7.2 H 301 l -11.7,17.4 h -6.5 c -4,0 -7.3,3.2 -7.3,7.2 0,3 1.9,5.6 4.5,6.7 l -11.4,17 h 1 L 343.3,80.6 h -0.8 z"
class="st0"
id="XMLID_8_" /><path
style="fill:#f9f9f9"
d="m 133.1,94 c -24.2,0 -41.8,18.3 -41.8,40.6 v 0.2 c 0,22.3 17.4,40.4 41.6,40.4 24.2,0 41.8,-18.3 41.8,-40.6 v -0.2 C 174.7,112.1 157.3,94 133.1,94 Z m 23.7,40.8 c 0,13.5 -9.8,24.5 -23.7,24.5 -13.9,0 -23.9,-11.2 -23.9,-24.7 v -0.2 c 0,-13.5 9.8,-24.5 23.7,-24.5 13.9,0 23.9,11.2 23.9,24.7 z"
class="st0"
id="XMLID_5_" /><circle
style="fill:#f9f9f9"
r="7.4000001"
cy="134.60001"
cx="132.5"
class="st0"
id="XMLID_4_" /><path
style="fill:#f9f9f9"
d="m 250.8,94.7 c -4.7,0 -8.5,3.8 -8.5,8.5 v 40.5 L 209.1,99.9 c -2.4,-3 -4.7,-5.1 -8.9,-5.1 h -1.8 c -4.8,0 -8.8,3.9 -8.8,8.7 V 166 c 0,4.7 4,8.5 8.7,8.5 4.7,0 8.7,-3.8 8.7,-8.5 v -42 l 34.5,45.4 c 2.4,3 4.7,5.1 8.9,5.1 h 0.6 c 4.8,0 8.5,-3.9 8.5,-8.8 v -62.5 c -0.1,-4.7 -3.9,-8.5 -8.7,-8.5 z"
class="st0"
id="XMLID_3_" /></g></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

@ -0,0 +1,21 @@
{{ define "meta" }}
<title>Forbidden</title>
{{ end }}
{{ define "content" }}
<section class="hero is-warning is-fullheight">
<div class="hero-body">
<div class="container">
<div class="columns">
<div class="column is-8-desktop is-offset-2-desktop">
<h1 class="title is-2 is-spaced">Forbidden</h1>
<h2 class="subtitle is-4">
The received the request was not processed.
For example, the CSRF validation may have failed.
</h2>
</div>
</div>
</div>
</div>
</section>
{{ end }}

View file

@ -0,0 +1,76 @@
{{ define "base" }}# Client configuration for {{ .User }}@{{ .Name }}
client
dev tun
remote ovpn.oneofftech.xyz 443 udp
remote ovpn.oneofftech.xyz 443 tcp
resolv-retry infinite
nobind
persist-key
persist-tun
cipher AES-256-CBC
auth SHA512
ns-cert-type server
key-direction 1
tls-version-min 1.2
;comp-lzo
verb 3
route 172.31.1.100 255.255.255.255 net_gateway
<ca>
-----BEGIN CERTIFICATE-----
MIIDwDCCAqigAwIBAgIJAMvRC7FajlAOMA0GCSqGSIb3DQEBCwUAMHUxCzAJBgNV
BAYTAkRFMQ8wDQYDVQQIDAZCZXJsaW4xDzANBgNVBAcMBkJlcmxpbjETMBEGA1UE
CgwKT25lT2ZmVGVjaDEWMBQGA1UECwwNSVQgZGVwYXJ0bWVudDEXMBUGA1UEAwwO
Y2EtY2VydGlmaWNhdGUwHhcNMTgwMTI1MTM0NjI3WhcNMjMwMTI0MTM0NjI3WjB1
MQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJsaW4x
EzARBgNVBAoMCk9uZU9mZlRlY2gxFjAUBgNVBAsMDUlUIGRlcGFydG1lbnQxFzAV
BgNVBAMMDmNhLWNlcnRpZmljYXRlMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEA5LzVrHqz33L5YiFs1HOZWvLht9yQ6+AxK1+RDZsx8490UEYvPnguyU/c
8NtaZPtWOg5Qvnh+0tHpLHV+3WbWyIObkix6b3U5EgR6Hgdf1zuzX7y/S2o7uPT1
zkCgIi9EQfy0IDIhIErsO0dOWndFt/cAfrMaOx0LV/kzr9bKdgg7WLQoVzUgawZq
ROScZUogaElISxC/C77YaGg9V5sV9qTa3uZ9DxuESzXLGMDx3DJMjH+Yu+nhJjoc
isSxK5qEnfqWJhZgJFTAY2BRbcMFMieVz/+UGk2GDZf1tpMZOQKxwrNibe4HO8zo
lfhX+H+sb4QZCdn30eUGstK/jJdQrQIDAQABo1MwUTAdBgNVHQ4EFgQU9UASoCXR
ountXC2vQ4s9BT5qGRYwHwYDVR0jBBgwFoAU9UASoCXRountXC2vQ4s9BT5qGRYw
DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEA2YgYuFKMzoblpPf+
VcyFKAXC9IoOJFeoA8FWMLBy38FedpCP+aFtlnG5eSLB/Xy7rdJK+7ASrdbAsFMD
U6P2guqUix4veIBZK0WLGTLfRKHQiOUqNP1zZpWsdrwUoUjGOEt4iqG9PCcaANSg
mOfl/BK+MtuRevF6Ry2JAZDArUXrXXjNdRXKB7iNc3Sd5icII53OGXXtn1ehzZXL
djbdz4MZa1kbA1ZlJVaYCRzOS/F9kU2aQceO17foxI5BvnOkpONLXDZHs61/KtYu
5z7hJoH49+4iyWZuRgWT/sq36qpvu+/f48JPxzqV94Jp77Z9BocTIjdfqHM++X9h
Yo95ZQ==
-----END CERTIFICATE-----
</ca>
<cert>
{{ .Cert | html }}</cert>
<key>
{{ .Key | html }}</key>
<tls-auth>
#
# 2048 bit OpenVPN static key
#
-----BEGIN OpenVPN Static key V1-----
187be23c2b3b0a6a9d79bc5b5c95b70b
b43b6b303e7c00eb75121d68df470ea3
3cdd0ddedc273f5412f181709890ea32
7086cbc5b21bbaf3dd231d115b5ba986
1b1aee31bff9be5f6c6f6dd490d593a1
eec50bb866558c6b2c6fe62ebfe125d9
34b7115e72d94ce08cc9e1c4e8ccdbfe
a5ee19ac0aec60da63df881e3c2e7d4d
9c4f167ec1b46309f17c16c36683780b
bed7551ad7a3d526c19014567370122e
98ae0ae7fd83a8a6de09883fcc181b36
a8465c0deda7a345ec3d16a4daf3fbf5
23dc36a48e679c653b3cfc6dbaa150a7
7ace46081d2c3712ce655f4b8211f674
4d4688c2b3828f9208a80bf71e6e4554
ae09b91154a435995439ad576fcc72c1
-----END OpenVPN Static key V1-----
</tls-auth>
{{ end }}

View file

@ -1,39 +0,0 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ .CSRF_TOKEN }}" />
<meta name="csrf-param" content="csrf_token" />
<title>Admin</title>
<link rel="stylesheet" href="{{ assetURL "css/admin-style" }}">
</head>
<body class="admin">
{{ template "header" . }}
<div id="flash-container">
{{range .flashes}}
<div class="{{ .Class }}"{{ .Message }}</div>
{{end}}
</div>
<div class="container main-container">
{{ template "content" . }}
</div>
{{ template "footer" . }}
{{ template "sink" .}}
<script src="/public/assets/admin.js"></script>
</body>
</html>
{{ end }}
{{ define "header"}}{{end}}
{{ define "content"}}{{end}}
{{ define "footer"}}{{end}}
{{ define "sink"}}{{end}}

View file

@ -6,41 +6,40 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ .CSRF_TOKEN }}" /> <meta name="csrf-token" content="{{ .csrfToken }}" />
<meta name="csrf-param" content="csrf_token" /> <meta name="csrf-param" content="csrf_token" />
<link rel="stylesheet" href='{{ assetURL "vendor/bulma.css" }}'> <link rel="stylesheet" href='{{ asset "vendor/bulma.css" }}'>
<link rel="stylesheet" href='{{ assetURL "css/style.css" }}'> <link rel="stylesheet" href='{{ asset "css/style.css" }}'>
{{ template "meta" . }} {{ template "meta" . }}
{{ if eq .Meta.Env "production" }} {{ if eq .Meta.Env "production" }}
<!-- Add meta tags specific to production environment (analytics etc) --> <!-- Add meta tags specific to production environment (analytics etc) -->
{{ end }} {{ end }}
<script defer src="https://use.fontawesome.com/releases/v5.0.6/js/all.js"
integrity="sha384-0AJY8UERsBUKdWcyF3o2kisLKeIo6G4Tbd8Y6fbyw6qYmn4WBuqcvxokp8m2UzSD"
crossorigin="anonymous"></script>
</head> </head>
<body> <body>
{{ template "header" . }} {{ if .flashes}}
<div id="flash-container"> <div id="flash-container">
{{range .flashes}} {{range .flashes}}
<div class="{{ .Class }}">{{ .Message }}</div> {{ .Render }}
{{end}} {{end}}
</div> </div>
{{ end }}
<div class="main-container"> <div class="main-container">
{{ template "content" . }} {{ template "content" . }}
</div> </div>
{{ template "footer" . }}
{{ template "sink" .}}
<script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script> <script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
<script>window.jQuery || document.write('<script src="{{ assetURL "vendor/jquery-3.2.1.min.js" }}"><\/script>')</script> <script>window.jQuery || document.write('<script src="{{ asset "vendor/jquery-3.2.1.min.js" }}"><\/script>')</script>
<script src='{{ assetURL "js/main.js" }}' async></script> <script src='{{ asset "js/main.js" }}' async></script>
</body> </body>
</html> </html>
{{ end }} {{ end }}
{{ define "meta"}}{{end}} {{ define "meta"}}{{end}}
{{ define "header"}}{{end}}
{{ define "content"}}{{end}} {{ define "content"}}{{end}}
{{ define "footer"}}{{end}}
{{ define "sink"}}{{end}}

View file

@ -0,0 +1,56 @@
{{ define "base" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="csrf-token" content="{{ .csrfToken }}" />
<meta name="csrf-param" content="csrf_token" />
<link rel="stylesheet" href='{{ asset "vendor/bulma.css" }}'>
<link rel="stylesheet" href='{{ asset "css/style.css" }}'>
{{ template "meta" . }}
{{ if eq .Meta.Env "production" }}
<!-- Add meta tags specific to production environment (analytics etc) -->
{{ end }}
<script defer src="https://use.fontawesome.com/releases/v5.0.6/js/all.js"
integrity="sha384-0AJY8UERsBUKdWcyF3o2kisLKeIo6G4Tbd8Y6fbyw6qYmn4WBuqcvxokp8m2UzSD"
crossorigin="anonymous"></script>
</head>
<body>
<section class="hero is-info is-bold is-fullheight">
{{ if .flashes}}
<div class="hero-head" id="flash-container">
{{range .flashes}}
{{ .Render }}
{{end}}
</div>
{{ end }}
<div class="hero-body">
<div class="container">
<div class="columns is-centered">
<div class="column is-one-third">
<div class="columns is-centered">
<div class="column is-half is-half-mobile is-offset-one-quarter-mobile">
<img src="{{ asset "img/logo.svg" }}"/>
</div>
</div>
{{ template "content" . }}
</div>
</div>
</div>
</div>
</section>
<script src="https://code.jquery.com/jquery-3.2.1.min.js" integrity="sha256-hwg4gsxgFZhOsEEamdOYGBf13FyQuiTwlAQgxVSNgt4=" crossorigin="anonymous"></script>
<script>window.jQuery || document.write('<script src="{{ asset "vendor/jquery-3.2.1.min.js" }}"><\/script>')</script>
<script src='{{ asset "js/main.js" }}' async></script>
</body>
</html>
{{ end }}
{{ define "meta"}}{{end}}

View file

@ -1,13 +0,0 @@
{{ define "footer" }}
<footer class="footer">
<div class="container">
<div class="columns">
<div class="column is-one-third">
<p>
&copy; 2017 OneOffTech
</p>
</div>
</div>
</div>
</footer>
{{ end }}

View file

@ -1,25 +0,0 @@
{{ define "header" }}
<nav class="navbar container" role="navigation" aria-label="main navigation">
<div class="navbar-brand">
<a class="navbar-item" href="#">
<img src="{{ assetURL "img/logo.png" }}" alt="Logo" width="112" height="28">
</a>
<button class="button navbar-burger">
<!-- Burger patties -->
<span></span>
<span></span>
<span></span>
</button>
</div>
<div class="navbar-menu">
<div class="navbar-start">
<a class="navbar-item">Certificates</a>
<a class="navbar-item">Change Password</a>
</div>
<div class="navbar-end">
</div>
<!-- navbar start, navbar end -->
</div>
</nav>
{{ end }}

View file

@ -0,0 +1,68 @@
{{ define "meta" }}
<title>Certificate List</title>
{{ end}}
{{ define "content" }}
<section class="content">
<div class="section">
<div class="container">
<div class="columns">
<div class="column">
<h1 class="title">Certificates for {{ .username }}:</h1>
<table class="table">
<thead>
<th>Device</th>
<th width="20%">Created</th>
<th width="20%" class="has-text-centered">Actions</th>
</thead>
<tfoot>
<form action="/certs/new" method="POST">
<th colspan="2">
<div class="field has-addons">
<p class="control is-marginless">
<a class="button is-static">
{{ $.username }}@
</a>
</p>
<p class="control is-marginless is-expanded">
<input name="certname" class="input" type="text" placeholder="Certificate name (e.g. Laptop)">
</p>
</div>
</th>
<th>{{ .csrfField }}<input type="submit" class="button is-success is-fullwidth" value="Create"/></th>
</form>
</tfoot>
<tbody>
{{ range .Clients }}
<tr>
<td class="is-vcentered"><p>{{ .User }}@{{ .Name }}</p></td>
<td><time title="{{ .CreatedAt.UTC }}">{{ .CreatedAt | humanDate }}</time></td>
<td>
<div class="field has-addons">
<p class="control is-marginless is-expanded">
<a href="/certs/download/{{ .Name }}" class="button is-primary is-fullwidth">Download</a>
</p>
<div class="control is-marginless">
<form action="/certs/delete/{{ .Name }}" method="POST">
{{ $.csrfField }}
<button class="button is-danger" type="submit">
<span class="icon is-small">
<i class="fas fa-trash"></i>
</span>
</button>
</form>
</div>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
{{ end}}

View file

@ -1,17 +0,0 @@
{{ define "meta" }}
<title>Landing Page</title>
<meta name="description" content="Test boilerplate" />
{{ end}}
{{ define "content" }}
<section class="content">
<div class="container">
<div class="columns">
<div class="column">
<h1>Hello, World!</h1>
<p>Have some variables: {{.}}</p>
</div>
</div>
</div>
</section>
{{ end}}

View file

@ -1,24 +1,41 @@
{{ define "meta" }} {{ define "meta" }}<title>Log In</title>{{ end }}
<title>Log in</title>
<meta name="description" content="Test boilerplate" />
{{ end}}
{{ define "content" }} {{ define "content" }}
<section class="content"> <div class="box is-shadowless">
<div class="section"> <div class="content has-text-centered" style="padding: 0 40px;">
<div class="container"> <h1 class="title has-text-dark">Log In</h1>
<div class="columns"> <form action="" method="POST" class="control">
<div class="column"> <div class="field">
<h1>Hello, World!</h1> <label for="email" class="label is-hidden">Email Address</label>
<form action="" method="POST"> <div class="control has-icons-left">
<input name="username" type="text" value=""/> <input class="input is-medium is-shadowless" id="email" name="email" spellcheck="false" label="false" type="" placeholder="Email Address" value="" autofocus />
<input name="password" type="text" value=""/> <span class="icon is-medium is-left">
<i class="fas fa-at"></i>
</span>
</div>
</div>
<div class="field">
<label for="password" class="label is-hidden">Password</label>
<div class="control has-icons-left">
<input class="input is-medium is-shadowless" id="password" name="password" type="password" placeholder="Password" value=""/>
<span class="icon is-medium is-left">
<i class="fas fa-key"></i>
</span>
</div>
</div>
{{ .csrfField }} {{ .csrfField }}
<input class="button is-primary" type="submit" value="Log in"> <div class="field is-grouped-right">
<input class="button is-success is-fullwidth is-medium" type="submit" value="Log In">
</div>
<div class="field has-text-centered">
<p><a href="/forgot-password">Forgot Password?</a></p>
</div>
</form> </form>
</div> </div>
</div> </div>
<div class="field">
<p class="has-text-white has-text-centered">
Don't have an account? <a class="has-text-weight-bold" href="/register">Sign Up</a>
</p>
</div> </div>
</div> {{ end }}
</section>
{{ end}}

75
handlers/auth.go Normal file
View file

@ -0,0 +1,75 @@
package handlers
import (
"encoding/json"
"fmt"
"net/http"
"os"
"git.klink.asia/paul/certman/views"
"golang.org/x/oauth2"
"git.klink.asia/paul/certman/services"
)
func OAuth2Endpoint(p *services.Provider, config *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.NewWithSession(req, p.Sessions)
code := req.FormValue("code")
// exchange code for token
accessToken, err := config.Exchange(oauth2.NoContext, code)
if err != nil {
fmt.Println(err)
http.NotFound(w, req)
return
}
if accessToken.Valid() {
// generate a client using the access token
httpClient := config.Client(oauth2.NoContext, accessToken)
apiRequest, err := http.NewRequest("GET", os.Getenv("USER_ENDPOINT"), nil)
if err != nil {
v.RenderError(w, http.StatusNotFound)
return
}
resp, err := httpClient.Do(apiRequest)
if err != nil {
fmt.Println(err.Error())
v.RenderError(w, http.StatusInternalServerError)
return
}
var user struct {
Username string `json:"username"`
}
err = json.NewDecoder(resp.Body).Decode(&user)
if err != nil {
fmt.Println(err.Error())
v.RenderError(w, http.StatusInternalServerError)
return
}
if user.Username != "" {
p.Sessions.SetUsername(w, req, user.Username)
http.Redirect(w, req, "/certs", http.StatusFound)
return
}
fmt.Println(err.Error())
v.RenderError(w, http.StatusInternalServerError)
return
}
}
}
func GetLoginHandler(p *services.Provider, config *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
authURL := config.AuthCodeURL("", oauth2.AccessTypeOnline)
http.Redirect(w, req, authURL, http.StatusFound)
}
}

253
handlers/cert.go Normal file
View file

@ -0,0 +1,253 @@
package handlers
import (
"bytes"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"html/template"
"io/ioutil"
"log"
"math/big"
"net/http"
"strings"
"time"
"git.klink.asia/paul/certman/models"
"git.klink.asia/paul/certman/services"
"github.com/go-chi/chi"
"git.klink.asia/paul/certman/views"
)
func ListClientsHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.NewWithSession(req, p.Sessions)
username := p.Sessions.GetUsername(req)
clients, _ := p.ClientCollection.ListClientsForUser(username)
v.Vars["Clients"] = clients
v.Render(w, "client_list")
}
}
func CreateCertHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
username := p.Sessions.GetUsername(req)
certname := req.FormValue("certname")
// Validate certificate Name
if !IsByteLength(certname, 2, 64) || !IsDNSName(certname) {
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "The certificate name can only contain letters and numbers",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
return
}
// lowercase the certificate name, to avoid problems with the case
// insensitive matching inside OpenVPN
certname = strings.ToLower(certname)
// Load CA master certificate
caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key")
if err != nil {
log.Fatalf("error loading ca keyfiles: %s", err)
panic(err.Error())
}
// Generate Keypair
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatalf("Could not generate keypair: %s", err)
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "The certificate key could not be generated",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
return
}
// Generate Certificate
commonName := fmt.Sprintf("%s@%s", username, certname)
derBytes, err := CreateCertificate(commonName, key, caCert, caKey)
// Initialize new client config
client := models.Client{
Name: certname,
CreatedAt: time.Now(),
PrivateKey: x509.MarshalPKCS1PrivateKey(key),
Cert: derBytes,
User: username,
}
// Insert client into database
if err := p.ClientCollection.CreateClient(&client); err != nil {
log.Println(err.Error())
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "The certificate could not be added to the database",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
return
}
p.Sessions.Flash(w, req,
services.Flash{
Type: "success",
Message: "The certificate was created successfully",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
}
}
func DeleteCertHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.New(req)
// detemine own username
username := p.Sessions.GetUsername(req)
name := chi.URLParam(req, "name")
client, err := p.ClientCollection.GetClientByNameUser(name, username)
if err != nil {
v.RenderError(w, http.StatusNotFound)
return
}
err = p.ClientCollection.DeleteClient(client.ID)
if err != nil {
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "Failed to delete certificate",
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
}
p.Sessions.Flash(w, req,
services.Flash{
Type: "success",
Message: template.HTML(fmt.Sprintf("Successfully deleted client <strong>%s</strong>", client.Name)),
},
)
http.Redirect(w, req, "/certs", http.StatusFound)
}
}
func DownloadCertHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.New(req)
// detemine own username
username := p.Sessions.GetUsername(req)
name := chi.URLParam(req, "name")
client, err := p.ClientCollection.GetClientByNameUser(name, username)
if err != nil {
v.RenderError(w, http.StatusNotFound)
return
}
// cbuf and kbuf are buffers in which the PEM certificates are
// rendered into
var cbuf = new(bytes.Buffer)
var kbuf = new(bytes.Buffer)
pem.Encode(cbuf, &pem.Block{Type: "CERTIFICATE", Bytes: client.Cert})
pem.Encode(kbuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: client.PrivateKey})
vars := map[string]string{
"Cert": cbuf.String(),
"Key": kbuf.String(),
}
t, err := views.GetTemplate("config.ovpn")
if err != nil {
log.Printf("Error loading certificate template: %s", err)
v.RenderError(w, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/x-openvpn-profile")
w.Header().Set("Content-Disposition", "attachment; filename=\"config.ovpn\"")
w.WriteHeader(http.StatusOK)
t.Execute(w, vars)
return
}
}
func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateKey, error) {
cf, err := ioutil.ReadFile(certFile)
if err != nil {
return nil, nil, err
}
kf, err := ioutil.ReadFile(keyFile)
if err != nil {
return nil, nil, err
}
cpb, _ := pem.Decode(cf)
kpb, _ := pem.Decode(kf)
crt, err := x509.ParseCertificate(cpb.Bytes)
if err != nil {
return nil, nil, err
}
key, err := x509.ParsePKCS1PrivateKey(kpb.Bytes)
if err != nil {
return nil, nil, err
}
return crt, key, nil
}
// CreateCertificate creates a CA-signed certificate
func CreateCertificate(commonName string, key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) {
subj := caCert.Subject
// .. except for the common name
subj.CommonName = commonName
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatalf("Obscure error in cert serial number generation: %s", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: subj,
NotBefore: time.Now().Add(-5 * time.Minute), // account for clock shift
NotAfter: time.Now().Add(24 * time.Hour * 356 * 5), // 5 years ought to be enough!
SignatureAlgorithm: x509.SHA256WithRSA,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
return x509.CreateCertificate(rand.Reader, &template, caCert, publicKey(key), caKey)
}
func publicKey(priv interface{}) interface{} {
switch k := priv.(type) {
case *rsa.PrivateKey:
return &k.PublicKey
case *ecdsa.PrivateKey:
return &k.PublicKey
default:
return nil
}
}

64
handlers/converters.go Normal file
View file

@ -0,0 +1,64 @@
package handlers
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
)
// ToString convert the input to a string.
func ToString(obj interface{}) string {
res := fmt.Sprintf("%v", obj)
return string(res)
}
// ToJSON convert the input to a valid JSON string
func ToJSON(obj interface{}) (string, error) {
res, err := json.Marshal(obj)
if err != nil {
res = []byte("")
}
return string(res), err
}
// ToFloat convert the input string to a float, or 0.0 if the input is not a float.
func ToFloat(str string) (float64, error) {
res, err := strconv.ParseFloat(str, 64)
if err != nil {
res = 0.0
}
return res, err
}
// ToInt convert the input string or any int type to an integer type 64, or 0 if the input is not an integer.
func ToInt(value interface{}) (res int64, err error) {
val := reflect.ValueOf(value)
switch value.(type) {
case int, int8, int16, int32, int64:
res = val.Int()
case uint, uint8, uint16, uint32, uint64:
res = int64(val.Uint())
case string:
if IsInt(val.String()) {
res, err = strconv.ParseInt(val.String(), 0, 64)
if err != nil {
res = 0
}
} else {
err = fmt.Errorf("math: square root of negative number %g", value)
res = 0
}
default:
err = fmt.Errorf("math: square root of negative number %g", value)
res = 0
}
return
}
// ToBoolean convert the input string to a boolean.
func ToBoolean(str string) (bool, error) {
return strconv.ParseBool(str)
}

View file

@ -1,115 +0,0 @@
package handlers
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"io/ioutil"
"log"
"math/big"
"net/http"
"time"
"git.klink.asia/paul/certman/views"
"github.com/jinzhu/gorm"
)
func ListCertHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.New(req)
v.Render(w, "cert_list")
}
}
func GenCertHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.New(req)
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
log.Fatalf("Could not generate keypair: %s", err)
}
caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key")
if err != nil {
v.Render(w, "500")
log.Fatalf("error loading ca keyfiles: %s", err)
}
derBytes, err := CreateCertificate(key, caCert, caKey)
pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
pkBytes := x509.MarshalPKCS1PrivateKey(key)
pem.Encode(w, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkBytes})
return
}
}
func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateKey, error) {
cf, err := ioutil.ReadFile(certFile)
if err != nil {
return nil, nil, err
}
kf, err := ioutil.ReadFile(keyFile)
if err != nil {
return nil, nil, err
}
cpb, cr := pem.Decode(cf)
fmt.Println(string(cr))
kpb, kr := pem.Decode(kf)
fmt.Println(string(kr))
crt, err := x509.ParseCertificate(cpb.Bytes)
if err != nil {
return nil, nil, err
}
key, err := x509.ParsePKCS1PrivateKey(kpb.Bytes)
if err != nil {
return nil, nil, err
}
return crt, key, nil
}
// CreateCertificate creates a CA-signed certificate
func CreateCertificate(key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) {
subj := caCert.Subject
// .. except for the common name
subj.CommonName = "clientName"
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
log.Fatalf("Obscure error in cert serial number generation: %s", err)
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: subj,
NotBefore: time.Now(),
NotAfter: time.Now().Add(24 * time.Hour * 356 * 5),
SignatureAlgorithm: x509.SHA256WithRSA,
//KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
return x509.CreateCertificate(rand.Reader, &template, caCert, publicKey(key), caKey)
}
func publicKey(priv interface{}) interface{} {
switch k := priv.(type) {
case *rsa.PrivateKey:
return &k.PublicKey
case *ecdsa.PrivateKey:
return &k.PublicKey
default:
return nil
}
}

View file

@ -15,3 +15,8 @@ func ErrorHandler(w http.ResponseWriter, req *http.Request) {
view := views.New(req) view := views.New(req)
view.RenderError(w, http.StatusInternalServerError) view.RenderError(w, http.StatusInternalServerError)
} }
func CSRFErrorHandler(w http.ResponseWriter, req *http.Request) {
view := views.New(req)
view.RenderError(w, http.StatusForbidden)
}

View file

@ -1,33 +0,0 @@
package handlers
import (
"net/http"
"git.klink.asia/paul/certman/models"
"github.com/jinzhu/gorm"
)
func LoginHandler(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
// Get parameters
username := req.Form.Get("username")
password := req.Form.Get("password")
user := models.User{}
err := db.Where(&models.User{Username: username}).Find(&user).Error
if err != nil {
// could not find user
http.Redirect(w, req, "/login", http.StatusFound)
}
if err := user.CheckPassword(password); err != nil {
// wrong password
http.Redirect(w, req, "/login", http.StatusFound)
}
// user is logged in
// set cookie
http.Redirect(w, req, "/certs", http.StatusFound)
}
}

138
handlers/validators.go Normal file
View file

@ -0,0 +1,138 @@
package handlers
import (
"net"
"regexp"
"strings"
)
const (
Email string = "^(((([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+(\\.([a-zA-Z]|\\d|[!#\\$%&'\\*\\+\\-\\/=\\?\\^_`{\\|}~]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])+)*)|((\\x22)((((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(([\\x01-\\x08\\x0b\\x0c\\x0e-\\x1f\\x7f]|\\x21|[\\x23-\\x5b]|[\\x5d-\\x7e]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(\\([\\x01-\\x09\\x0b\\x0c\\x0d-\\x7f]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}]))))*(((\\x20|\\x09)*(\\x0d\\x0a))?(\\x20|\\x09)+)?(\\x22)))@((([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|\\.|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|\\d|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.)+(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])|(([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])([a-zA-Z]|\\d|-|_|~|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])*([a-zA-Z]|[\\x{00A0}-\\x{D7FF}\\x{F900}-\\x{FDCF}\\x{FDF0}-\\x{FFEF}])))\\.?$"
CreditCard string = "^(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|6(?:011|5[0-9][0-9])[0-9]{12}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|(?:2131|1800|35\\d{3})\\d{11})$"
ISBN10 string = "^(?:[0-9]{9}X|[0-9]{10})$"
ISBN13 string = "^(?:[0-9]{13})$"
UUID3 string = "^[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[0-9a-f]{4}-[0-9a-f]{12}$"
UUID4 string = "^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
UUID5 string = "^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
UUID string = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
Alpha string = "^[a-zA-Z]+$"
Alphanumeric string = "^[a-zA-Z0-9]+$"
Numeric string = "^[0-9]+$"
Int string = "^(?:[-+]?(?:0|[1-9][0-9]*))$"
Float string = "^(?:[-+]?(?:[0-9]+))?(?:\\.[0-9]*)?(?:[eE][\\+\\-]?(?:[0-9]+))?$"
Hexadecimal string = "^[0-9a-fA-F]+$"
Hexcolor string = "^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$"
RGBcolor string = "^rgb\\(\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*,\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*,\\s*(0|[1-9]\\d?|1\\d\\d?|2[0-4]\\d|25[0-5])\\s*\\)$"
ASCII string = "^[\x00-\x7F]+$"
Multibyte string = "[^\x00-\x7F]"
FullWidth string = "[^\u0020-\u007E\uFF61-\uFF9F\uFFA0-\uFFDC\uFFE8-\uFFEE0-9a-zA-Z]"
HalfWidth string = "[\u0020-\u007E\uFF61-\uFF9F\uFFA0-\uFFDC\uFFE8-\uFFEE0-9a-zA-Z]"
Base64 string = "^(?:[A-Za-z0-9+\\/]{4})*(?:[A-Za-z0-9+\\/]{2}==|[A-Za-z0-9+\\/]{3}=|[A-Za-z0-9+\\/]{4})$"
PrintableASCII string = "^[\x20-\x7E]+$"
DataURI string = "^data:.+\\/(.+);base64$"
Latitude string = "^[-+]?([1-8]?\\d(\\.\\d+)?|90(\\.0+)?)$"
Longitude string = "^[-+]?(180(\\.0+)?|((1[0-7]\\d)|([1-9]?\\d))(\\.\\d+)?)$"
DNSName string = `^([a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62}){1}(\.[a-zA-Z0-9_]{1}[a-zA-Z0-9_-]{0,62})*[\._]?$`
IP string = `(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))`
URLSchema string = `((ftp|tcp|udp|wss?|https?):\/\/)`
URLUsername string = `(\S+(:\S*)?@)`
URLPath string = `((\/|\?|#)[^\s]*)`
URLPort string = `(:(\d{1,5}))`
URLIP string = `([1-9]\d?|1\d\d|2[01]\d|22[0-3])(\.(1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.([0-9]\d?|1\d\d|2[0-4]\d|25[0-4]))`
URLSubdomain string = `((www\.)|([a-zA-Z0-9]([-\.][-\._a-zA-Z0-9]+)*))`
URL string = `^` + URLSchema + `?` + URLUsername + `?` + `((` + URLIP + `|(\[` + IP + `\])|(([a-zA-Z0-9]([a-zA-Z0-9-_]+)?[a-zA-Z0-9]([-\.][a-zA-Z0-9]+)*)|(` + URLSubdomain + `?))?(([a-zA-Z\x{00a1}-\x{ffff}0-9]+-?-?)*[a-zA-Z\x{00a1}-\x{ffff}0-9]+)(?:\.([a-zA-Z\x{00a1}-\x{ffff}]{1,}))?))\.?` + URLPort + `?` + URLPath + `?$`
SSN string = `^\d{3}[- ]?\d{2}[- ]?\d{4}$`
WinPath string = `^[a-zA-Z]:\\(?:[^\\/:*?"<>|\r\n]+\\)*[^\\/:*?"<>|\r\n]*$`
UnixPath string = `^(/[^/\x00]*)+/?$`
Semver string = "^v?(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(-(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(\\.(0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\\+[0-9a-zA-Z-]+(\\.[0-9a-zA-Z-]+)*)?$"
tagName string = "valid"
hasLowerCase string = ".*[[:lower:]]"
hasUpperCase string = ".*[[:upper:]]"
)
var (
rxEmail = regexp.MustCompile(Email)
rxCreditCard = regexp.MustCompile(CreditCard)
rxISBN10 = regexp.MustCompile(ISBN10)
rxISBN13 = regexp.MustCompile(ISBN13)
rxUUID3 = regexp.MustCompile(UUID3)
rxUUID4 = regexp.MustCompile(UUID4)
rxUUID5 = regexp.MustCompile(UUID5)
rxUUID = regexp.MustCompile(UUID)
rxAlpha = regexp.MustCompile(Alpha)
rxAlphanumeric = regexp.MustCompile(Alphanumeric)
rxNumeric = regexp.MustCompile(Numeric)
rxInt = regexp.MustCompile(Int)
rxFloat = regexp.MustCompile(Float)
rxHexadecimal = regexp.MustCompile(Hexadecimal)
rxHexcolor = regexp.MustCompile(Hexcolor)
rxRGBcolor = regexp.MustCompile(RGBcolor)
rxASCII = regexp.MustCompile(ASCII)
rxPrintableASCII = regexp.MustCompile(PrintableASCII)
rxMultibyte = regexp.MustCompile(Multibyte)
rxFullWidth = regexp.MustCompile(FullWidth)
rxHalfWidth = regexp.MustCompile(HalfWidth)
rxBase64 = regexp.MustCompile(Base64)
rxDataURI = regexp.MustCompile(DataURI)
rxLatitude = regexp.MustCompile(Latitude)
rxLongitude = regexp.MustCompile(Longitude)
rxDNSName = regexp.MustCompile(DNSName)
rxURL = regexp.MustCompile(URL)
rxSSN = regexp.MustCompile(SSN)
rxWinPath = regexp.MustCompile(WinPath)
rxUnixPath = regexp.MustCompile(UnixPath)
rxSemver = regexp.MustCompile(Semver)
rxHasLowerCase = regexp.MustCompile(hasLowerCase)
rxHasUpperCase = regexp.MustCompile(hasUpperCase)
)
// IsAlphanumeric check if the string contains only letters and numbers. Empty string is valid.
func IsAlphanumeric(str string) bool {
if IsNull(str) {
return true
}
return rxAlphanumeric.MatchString(str)
}
// ByteLength check string's length
func ByteLength(str string, params ...string) bool {
if len(params) == 2 {
min, _ := ToInt(params[0])
max, _ := ToInt(params[1])
return len(str) >= int(min) && len(str) <= int(max)
}
return false
}
// IsInt check if the string is an integer. Empty string is valid.
func IsInt(str string) bool {
if IsNull(str) {
return true
}
return rxInt.MatchString(str)
}
// IsNull check if the string is null.
func IsNull(str string) bool {
return len(str) == 0
}
// IsByteLength check if the string's length (in bytes) falls in a range.
func IsByteLength(str string, min, max int) bool {
return len(str) >= min && len(str) <= max
}
// IsDNSName will validate the given string as a DNS name
func IsDNSName(str string) bool {
if str == "" || len(strings.Replace(str, ".", "", -1)) > 255 {
// constraints already violated
return false
}
return !IsIP(str) && rxDNSName.MatchString(str)
}
// IsIP checks if a string is either IP version 4 or 6.
func IsIP(str string) bool {
return net.ParseIP(str) != nil
}

47
main.go
View file

@ -1,36 +1,53 @@
package main package main
import ( import (
"errors"
"log" "log"
"net/http" "net/http"
"os"
"time"
"github.com/jinzhu/gorm" "git.klink.asia/paul/certman/services"
"git.klink.asia/paul/certman/models"
"git.klink.asia/paul/certman/router" "git.klink.asia/paul/certman/router"
"git.klink.asia/paul/certman/views" "git.klink.asia/paul/certman/views"
// import sqlite3 driver once
_ "github.com/mattn/go-sqlite3"
) )
func main() { func main() {
log.Println("Initializing certman")
// Connect to the database if err := checkCAFilesExist(); err != nil {
db, err := gorm.Open("sqlite3", "db.sqlite3") log.Fatalf("Could not read CA files: %s", err)
if err != nil {
log.Fatalf("Could not open database: %s", err.Error())
} }
defer db.Close()
// Migrate c := services.Config{
db.AutoMigrate(models.User{}, models.ClientConf{}) CollectionPath: "./clients.json",
Sessions: &services.SessionsConfig{
SessionName: "_session",
CookieKey: os.Getenv("APP_KEY"),
HttpOnly: true,
Lifetime: 24 * time.Hour,
},
}
log.Println(".. services")
serviceProvider := services.NewProvider(&c)
// load and parse template files // load and parse template files
log.Println(".. templates")
views.LoadTemplates() views.LoadTemplates()
mux := router.HandleRoutes(db) mux := router.HandleRoutes(serviceProvider)
err = http.ListenAndServe(":8000", mux) log.Println(".. server")
err := http.ListenAndServe(os.Getenv("APP_LISTEN"), mux)
log.Fatalf(err.Error()) log.Fatalf(err.Error())
} }
func checkCAFilesExist() error {
for _, filename := range []string{"ca.crt", "ca.key"} {
if _, err := os.Stat(filename); os.IsNotExist(err) {
return errors.New(filename + " not readable")
}
}
return nil
}

View file

@ -1,25 +1,22 @@
package middleware package middleware
import ( import (
"log"
"net/http" "net/http"
"runtime/debug"
"git.klink.asia/paul/certman/handlers" "git.klink.asia/paul/certman/services"
) )
func RequireLogin(next http.Handler) http.Handler { // RequireLogin is a middleware that checks for a username in the active
fn := func(w http.ResponseWriter, r *http.Request) { // session, and redirects to `/login` if no username was found.
defer func() { func RequireLogin(sessions *services.Sessions) func(http.Handler) http.Handler {
if rvr := recover(); rvr != nil { return func(next http.Handler) http.Handler {
log.Println(rvr) fn := func(w http.ResponseWriter, req *http.Request) {
log.Println(string(debug.Stack())) if username := sessions.GetUsername(req); username == "" {
handlers.ErrorHandler(w, r) http.Redirect(w, req, "/login", http.StatusFound)
}
}()
next.ServeHTTP(w, r)
} }
next.ServeHTTP(w, req)
}
return http.HandlerFunc(fn) return http.HandlerFunc(fn)
}
} }

31
models/model.go Normal file
View file

@ -0,0 +1,31 @@
package models
import (
"errors"
"time"
)
var (
// ErrNotImplemented gets thrown if some action was not attempted,
// because it is not implemented in the code yet.
ErrNotImplemented = errors.New("Not implemented")
)
// Client represent the OpenVPN client configuration
type Client struct {
ID uint
CreatedAt time.Time
Name string
User string
Cert []byte
PrivateKey []byte
}
type ClientProvider interface {
CountClients() (uint, error)
CreateClient(*Client) (*Client, error)
ListClients(count, offset int) ([]*Client, error)
ListClientsForUser(user string, count, offset int) ([]*Client, error)
GetClientByID(id uint) (*Client, error)
DeleteClient(id uint) error
}

View file

@ -1,42 +0,0 @@
package models
import (
"errors"
"github.com/jinzhu/gorm"
)
var (
// ErrNotImplemented gets thrown if some action was not attempted,
// because it is not implemented in the code yet.
ErrNotImplemented = errors.New("Not implemented")
)
// User represents a User of the system which is able to log in
type User struct {
gorm.Model
Username string
HashedPassword []byte
IsAdmin bool
}
// SetPassword sets the password of an user struct, but does not save it yet
func (u *User) SetPassword(password string) error {
return ErrNotImplemented
}
// CheckPassword compares a supplied plain text password with the internally
// stored password hash, returns error=nil on success.
func (u *User) CheckPassword(password string) error {
return ErrNotImplemented
}
// ClientConf represent the OpenVPN client configuration
type ClientConf struct {
gorm.Model
Name string
User User
Cert []byte
PublicKey []byte
PrivateKey []byte
}

View file

@ -5,13 +5,15 @@ import (
"os" "os"
"strings" "strings"
"git.klink.asia/paul/certman/services"
"golang.org/x/oauth2"
"git.klink.asia/paul/certman/assets" "git.klink.asia/paul/certman/assets"
"git.klink.asia/paul/certman/handlers" "git.klink.asia/paul/certman/handlers"
"git.klink.asia/paul/certman/views" "git.klink.asia/paul/certman/views"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/go-chi/chi/middleware" "github.com/go-chi/chi/middleware"
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
"github.com/jinzhu/gorm"
mw "git.klink.asia/paul/certman/middleware" mw "git.klink.asia/paul/certman/middleware"
) )
@ -24,16 +26,31 @@ var (
cookieKey = []byte("osx70sMD8HZG2ouUl8uKI4wcMugiJ2WH") cookieKey = []byte("osx70sMD8HZG2ouUl8uKI4wcMugiJ2WH")
) )
func HandleRoutes(db *gorm.DB) http.Handler { func HandleRoutes(provider *services.Provider) http.Handler {
mux := chi.NewMux() mux := chi.NewMux()
// mux.Use(middleware.RequestID) //mux.Use(middleware.RequestID)
mux.Use(middleware.Logger) mux.Use(middleware.Logger) // log requests
mux.Use(middleware.RealIP) mux.Use(middleware.RealIP) // use proxy headers
mux.Use(middleware.RedirectSlashes) mux.Use(middleware.RedirectSlashes) // redirect trailing slashes
mux.Use(mw.Recoverer) mux.Use(mw.Recoverer) // recover on panic
mux.Use(provider.Sessions.Manager.Use) // use session storage
// TODO: move this code away from here
oauth2Config := &oauth2.Config{
ClientID: os.Getenv("OAUTH2_CLIENT_ID"),
ClientSecret: os.Getenv("OAUTH2_CLIENT_SECRET"),
Scopes: []string{"read_user"},
RedirectURL: os.Getenv("OAUTH2_REDIRECT_URL"),
Endpoint: oauth2.Endpoint{
AuthURL: os.Getenv("OAUTH2_AUTH_URL"),
TokenURL: os.Getenv("OAUTH2_TOKEN_URL"),
},
}
// we are serving the static files directly from the assets package // we are serving the static files directly from the assets package
// this either means we use the embedded files, or live-load
// from the file system (if `--tags="dev"` is used).
fileServer(mux, "/static", assets.Assets) fileServer(mux, "/static", assets.Assets)
mux.Route("/", func(r chi.Router) { mux.Route("/", func(r chi.Router) {
@ -43,25 +60,26 @@ func HandleRoutes(db *gorm.DB) http.Handler {
csrf.Secure(false), csrf.Secure(false),
csrf.CookieName(csrfCookieName), csrf.CookieName(csrfCookieName),
csrf.FieldName(csrfFieldName), csrf.FieldName(csrfFieldName),
csrf.ErrorHandler(http.HandlerFunc(handlers.CSRFErrorHandler)),
)) ))
} }
r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { r.HandleFunc("/", http.RedirectHandler("certs", http.StatusFound).ServeHTTP)
view := views.New(req)
view.Render(w, "debug") r.Route("/login", func(r chi.Router) {
r.Get("/", handlers.GetLoginHandler(provider, oauth2Config))
r.Get("/oauth2/redirect", handlers.OAuth2Endpoint(provider, oauth2Config))
}) })
r.Get("/login", func(w http.ResponseWriter, req *http.Request) { r.Route("/certs", func(r chi.Router) {
view := views.New(req) r.Use(mw.RequireLogin(provider.Sessions))
view.Render(w, "login") r.Get("/", handlers.ListClientsHandler(provider))
r.Post("/new", handlers.CreateCertHandler(provider))
r.HandleFunc("/download/{name}", handlers.DownloadCertHandler(provider))
r.Post("/delete/{name}", handlers.DeleteCertHandler(provider))
}) })
r.Get("/certs", handlers.ListCertHandler(db)) r.Get("/unconfigured-backend", handlers.NotFoundHandler)
r.HandleFunc("/certs/new", handlers.GenCertHandler(db))
r.HandleFunc("/500", func(w http.ResponseWriter, req *http.Request) {
panic("500")
})
}) })
// what should happen if no route matches // what should happen if no route matches
@ -70,6 +88,14 @@ func HandleRoutes(db *gorm.DB) http.Handler {
return mux return mux
} }
// v is a helper function for quickly displaying a view
func v(template string) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
view := views.New(req)
view.Render(w, template)
}
}
// fileServer sets up a http.FileServer handler to serve // fileServer sets up a http.FileServer handler to serve
// static files from a http.FileSystem. // static files from a http.FileSystem.
func fileServer(r chi.Router, path string, root http.FileSystem) { func fileServer(r chi.Router, path string, root http.FileSystem) {

169
services/clientstore.go Normal file
View file

@ -0,0 +1,169 @@
package services
import (
"encoding/json"
"errors"
"io/ioutil"
"log"
"os"
"sync"
"git.klink.asia/paul/certman/models"
)
var (
ErrNilCertificate = errors.New("Trying to store nil certificate")
ErrDuplicate = errors.New("Client with that name already exists")
ErrUserNotExists = errors.New("User does not exist")
ErrClientNotExists = errors.New("Client does not exist")
)
type ClientCollection struct {
sync.RWMutex
path string
Clients map[uint]*models.Client
UserIndex map[string]map[string]uint
LastID uint
}
func NewClientCollection(path string) *ClientCollection {
// empty collection
var clientCollection = ClientCollection{
path: path,
Clients: make(map[uint]*models.Client),
UserIndex: make(map[string]map[string]uint),
LastID: 0,
}
raw, err := ioutil.ReadFile(path)
if os.IsNotExist(err) {
return &clientCollection
} else if err != nil {
log.Println(err)
return &clientCollection
}
if err := json.Unmarshal(raw, &clientCollection); err != nil {
log.Println(err)
}
return &clientCollection
}
// CreateClient inserts a client into the datastore
func (db *ClientCollection) CreateClient(client *models.Client) error {
db.Lock()
defer db.Unlock()
if client == nil {
return ErrNilCertificate
}
db.LastID++ // increment Id
client.ID = db.LastID
userIndex, exists := db.UserIndex[client.User]
if !exists {
// create user index if not exists
db.UserIndex[client.User] = make(map[string]uint)
userIndex = db.UserIndex[client.User]
}
if _, exists = userIndex[client.Name]; exists {
return ErrDuplicate
}
// if all went well, add client and set the index
db.Clients[client.ID] = client
userIndex[client.Name] = client.ID
db.UserIndex[client.User] = userIndex
return db.save()
}
// ListClientsForUser returns a slice of 'count' client for user 'user', starting at 'offset'
func (db *ClientCollection) ListClientsForUser(user string) ([]*models.Client, error) {
db.RLock()
defer db.RUnlock()
var clients = make([]*models.Client, 0)
userIndex, exists := db.UserIndex[user]
if !exists {
return nil, errors.New("user does not exist")
}
for _, clientID := range userIndex {
clients = append(clients, db.Clients[clientID])
}
return clients, nil
}
// GetClientByID returns a single client by ID
func (db *ClientCollection) GetClientByID(id uint) (*models.Client, error) {
client, exists := db.Clients[id]
if !exists {
return nil, ErrClientNotExists
}
return client, nil
}
// GetClientByNameUser returns a single client by ID
func (db *ClientCollection) GetClientByNameUser(name, user string) (*models.Client, error) {
db.RLock()
defer db.RUnlock()
userIndex, exists := db.UserIndex[user]
if !exists {
return nil, ErrUserNotExists
}
clientID, exists := userIndex[name]
if !exists {
return nil, ErrClientNotExists
}
client, exists := db.Clients[clientID]
if !exists {
return nil, ErrClientNotExists
}
return client, nil
}
// DeleteClient removes a client from the datastore
func (db *ClientCollection) DeleteClient(id uint) error {
db.Lock()
defer db.Unlock()
client, exists := db.Clients[id]
if !exists {
return nil // nothing to delete
}
userIndex, exists := db.UserIndex[client.User]
if !exists {
return ErrUserNotExists
}
delete(userIndex, client.Name) // delete client index
// if index is now empty, delete the user entry
if len(userIndex) == 0 {
delete(db.UserIndex, client.User)
}
// finally delete the client
delete(db.Clients, id)
return db.save()
}
func (c *ClientCollection) save() error {
collectionJSON, _ := json.Marshal(c)
return ioutil.WriteFile(c.path, collectionJSON, 0600)
}

21
services/provider.go Normal file
View file

@ -0,0 +1,21 @@
package services
type Config struct {
CollectionPath string
Sessions *SessionsConfig
}
type Provider struct {
ClientCollection *ClientCollection
Sessions *Sessions
}
// NewProvider returns the ServiceProvider
func NewProvider(conf *Config) *Provider {
var provider = &Provider{}
provider.ClientCollection = NewClientCollection(conf.CollectionPath)
provider.Sessions = NewSessions(conf.Sessions)
return provider
}

123
services/sessions.go Normal file
View file

@ -0,0 +1,123 @@
package services
import (
"encoding/gob"
"fmt"
"html/template"
"log"
"net/http"
"time"
"github.com/alexedwards/scs"
)
var (
// FlashesKey is the key used for the flashes in the cookie
FlashesKey = "_flashes"
// UserEmailKey is the key used to reference usernames
UserEmailKey = "_user_email"
)
func init() {
// Register the Flash message type, so gob can serialize it
gob.Register(Flash{})
}
type SessionsConfig struct {
SessionName string
CookieKey string
HttpOnly bool
Secure bool
Lifetime time.Duration
}
// Sessions is a wrapped scs.Store in order to implement custom logic
type Sessions struct {
*scs.Manager
}
// NewSessions populates the default sessions Store
func NewSessions(conf *SessionsConfig) *Sessions {
store := scs.NewCookieManager(
conf.CookieKey,
)
store.Name(conf.SessionName)
store.HttpOnly(true)
store.Lifetime(conf.Lifetime)
store.Secure(conf.Secure)
return &Sessions{store}
}
func (store *Sessions) GetUsername(req *http.Request) string {
if store == nil {
// if store was not initialized, all requests fail
log.Println("Nil pointer when checking session for username")
return ""
}
sess := store.Load(req)
email, err := sess.GetString(UserEmailKey)
if err != nil {
// Username found
return ""
}
// User is logged in
return email
}
func (store *Sessions) SetUsername(w http.ResponseWriter, req *http.Request, username string) {
if store == nil {
// if store was not initialized, do nothing
return
}
sess := store.Load(req)
// renew token to avoid session pinning/fixation attack
sess.RenewToken(w)
sess.PutString(w, UserEmailKey, username)
}
type Flash struct {
Message template.HTML
Type string
}
// Render renders the flash message as a notification box
func (flash Flash) Render() template.HTML {
return template.HTML(
fmt.Sprintf(
"<div class=\"notification is-radiusless is-%s\"><div class=\"container has-text-centered\">%s</div></div>",
flash.Type, flash.Message,
),
)
}
// Flash add flash message to session data
func (store *Sessions) Flash(w http.ResponseWriter, req *http.Request, flash Flash) error {
var flashes []Flash
sess := store.Load(req)
if err := sess.GetObject(FlashesKey, &flashes); err != nil {
return err
}
flashes = append(flashes, flash)
return sess.PutObject(w, FlashesKey, flashes)
}
// Flashes returns a slice of flash messages from session data
func (store *Sessions) Flashes(w http.ResponseWriter, req *http.Request) []Flash {
var flashes []Flash
sess := store.Load(req)
sess.PopObject(w, FlashesKey, &flashes)
return flashes
}

10
settings/settings.go Normal file
View file

@ -0,0 +1,10 @@
package settings
import "os"
func Get(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}

View file

@ -8,11 +8,13 @@ import (
) )
var funcs = template.FuncMap{ var funcs = template.FuncMap{
"assetURL": assetURLFn, "asset": assetURLFn,
"url": relURLFn,
"lower": lower, "lower": lower,
"upper": upper, "upper": upper,
"date": dateFn, "date": dateFn,
"humanDate": readableDateFn, "humanDate": readableDateFn,
"t": translateFn,
} }
func lower(input string) string { func lower(input string) string {
@ -28,6 +30,11 @@ func assetURLFn(input string) string {
return fmt.Sprintf("%s%s", url, input) return fmt.Sprintf("%s%s", url, input)
} }
func relURLFn(input string) string {
url := "/" //os.Getenv("ASSET_URL")
return fmt.Sprintf("%s%s", url, input)
}
func dateFn(format string, input interface{}) string { func dateFn(format string, input interface{}) string {
var t time.Time var t time.Time
switch date := input.(type) { switch date := input.(type) {
@ -40,6 +47,10 @@ func dateFn(format string, input interface{}) string {
return t.Format(format) return t.Format(format)
} }
func translateFn(language string, text string) string {
return text
}
func readableDateFn(t time.Time) string { func readableDateFn(t time.Time) string {
if time.Now().Before(t) { if time.Now().Before(t) {
return "in the future" return "in the future"

View file

@ -18,11 +18,15 @@ var templates map[string]*template.Template
func LoadTemplates() { func LoadTemplates() {
templates = map[string]*template.Template{ templates = map[string]*template.Template{
"401": newTemplate("layouts/application.gohtml", "errors/401.gohtml"), "401": newTemplate("layouts/application.gohtml", "errors/401.gohtml"),
"403": newTemplate("layouts/application.gohtml", "errors/403.gohtml"),
"404": newTemplate("layouts/application.gohtml", "errors/404.gohtml"), "404": newTemplate("layouts/application.gohtml", "errors/404.gohtml"),
"500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"), "500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"),
"debug": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/debug.gohtml"), "login": newTemplate("layouts/auth.gohtml", "views/login.gohtml"),
"login": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/login.gohtml"),
"client_list": newTemplate("layouts/application.gohtml", "views/client_list.gohtml"),
"config.ovpn": newTemplate("files/config.ovpn"),
} }
return return
} }

View file

@ -7,12 +7,15 @@ import (
"log" "log"
"net/http" "net/http"
"git.klink.asia/paul/certman/services"
"github.com/gorilla/csrf" "github.com/gorilla/csrf"
) )
type View struct { type View struct {
Vars map[string]interface{} Vars map[string]interface{}
Request *http.Request Request *http.Request
SessionStore *services.Sessions
} }
func New(req *http.Request) *View { func New(req *http.Request) *View {
@ -25,6 +28,25 @@ func New(req *http.Request) *View {
"Path": req.URL.Path, "Path": req.URL.Path,
"Env": "develop", "Env": "develop",
}, },
"flashes": []services.Flash{},
"username": "",
},
}
}
func NewWithSession(req *http.Request, sessionStore *services.Sessions) *View {
return &View{
Request: req,
SessionStore: sessionStore,
Vars: map[string]interface{}{
"CSRF_TOKEN": csrf.Token(req),
"csrfField": csrf.TemplateField(req),
"Meta": map[string]interface{}{
"Path": req.URL.Path,
"Env": "develop",
},
"flashes": []services.Flash{},
"username": sessionStore.GetUsername(req),
}, },
} }
} }
@ -39,6 +61,11 @@ func (view View) Render(w http.ResponseWriter, name string) {
return return
} }
if view.SessionStore != nil {
// add flashes to template
view.Vars["flashes"] = view.SessionStore.Flashes(w, view.Request)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
t.Execute(w, view.Vars) t.Execute(w, view.Vars)
@ -49,12 +76,12 @@ func (view View) RenderError(w http.ResponseWriter, status int) {
var name string var name string
switch status { switch status {
case http.StatusNotFound:
name = "404"
case http.StatusUnauthorized: case http.StatusUnauthorized:
name = "401" name = "401"
case http.StatusForbidden: case http.StatusForbidden:
name = "403" name = "403"
case http.StatusNotFound:
name = "404"
default: default:
name = "500" name = "500"
} }