Merge branch 'develop'
Replace the internal authentication with an external oauth2 provider
This commit is contained in:
commit
ea14e74065
35 changed files with 1484 additions and 421 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
@ -1,3 +1,7 @@
|
||||||
*_vfsdata.go
|
*_vfsdata.go
|
||||||
certman
|
certman
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
*.crt
|
||||||
|
*.key
|
||||||
|
clients.json
|
||||||
|
.env
|
||||||
|
|
|
@ -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
23
Dockerfile
Normal 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
38
README.md
Normal 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 |
59
assets/static/img/logo.svg
Normal file
59
assets/static/img/logo.svg
Normal 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 |
21
assets/templates/errors/403.gohtml
Normal file
21
assets/templates/errors/403.gohtml
Normal 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 }}
|
76
assets/templates/files/config.ovpn
Normal file
76
assets/templates/files/config.ovpn
Normal 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 }}
|
|
@ -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}}
|
|
|
@ -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}}
|
|
56
assets/templates/layouts/auth.gohtml
Normal file
56
assets/templates/layouts/auth.gohtml
Normal 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}}
|
|
@ -1,13 +0,0 @@
|
||||||
{{ define "footer" }}
|
|
||||||
<footer class="footer">
|
|
||||||
<div class="container">
|
|
||||||
<div class="columns">
|
|
||||||
<div class="column is-one-third">
|
|
||||||
<p>
|
|
||||||
© 2017 OneOffTech
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
{{ end }}
|
|
|
@ -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 }}
|
|
68
assets/templates/views/client_list.gohtml
Normal file
68
assets/templates/views/client_list.gohtml
Normal 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}}
|
|
@ -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}}
|
|
|
@ -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
75
handlers/auth.go
Normal 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
253
handlers/cert.go
Normal 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
64
handlers/converters.go
Normal 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)
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
@ -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
138
handlers/validators.go
Normal 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
47
main.go
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
31
models/model.go
Normal 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
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
169
services/clientstore.go
Normal 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
21
services/provider.go
Normal 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
123
services/sessions.go
Normal 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
10
settings/settings.go
Normal 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
|
||||||
|
}
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue