Merge branch 'feature-oauth2' into develop

This replaces the builtin authentication with an external oauth2
provider (in this case provided by gitlab).
This commit is contained in:
Paul 2018-02-10 16:54:10 +01:00
commit 9b39c2f71b
30 changed files with 889 additions and 790 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,15 +45,15 @@ 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:
- certman - certman
# - certman.arm - certman.arm
# - certman.exe - certman.exe
minify: minify:
stage: release stage: release
@ -65,11 +65,26 @@ 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
#- certman.arm - certman.arm
#- 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`

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="{{ .csrfToken }}" />
<meta name="csrf-param" content="csrf_token" />
<title>Admin</title>
<link rel="stylesheet" href="{{ asset "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

@ -28,14 +28,11 @@
{{end}} {{end}}
</div> </div>
{{ end }} {{ end }}
{{ template "header" . }}
<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="{{ asset "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='{{ asset "js/main.js" }}' async></script> <script src='{{ asset "js/main.js" }}' async></script>
@ -45,7 +42,4 @@
{{ 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

@ -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,37 +0,0 @@
{{ define "header" }}
<section class="hero is-link">
<div class="hero-body">
<div class="container">
<div class="columns is-vcentered">
<div class="column">
<p class="title">
Certificate management
</p>
<p class="subtitle">
Generate a <strong>VPN configuration</strong>
</p>
</div>
</div>
</div>
</div>
<div class="hero-foot">
<div class="container">
<nav class="tabs is-boxed">
<ul>
<li class="is-active">
<a href="{{ url "" }}">Home</a>
</li>
<li>
<a href="{{ url "certs" }}">Certificates</a>
</li>
<li>
<a href="{{ url "users" }}">Users</a>
</li>
</ul>
</nav></div>
</div>
</section>
{{ end }}

View file

@ -1,24 +0,0 @@
{{ define "meta" }}
<title>Log in</title>
{{ end}}
{{ define "content" }}
<section class="content">
<div class="section">
<div class="container">
<div class="columns">
<div class="column">
<h1>Certificates</h1>
{{ if .Certificates }}
{{ range .Certificates }}
<li>{{ .User }}@{{ .Name }}</li>
{{ end }}
{{ else }}
<p>You don't have certificates yet!</p>
{{ end }}
</div>
</div>
</div>
</div>
</section>
{{ 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 +0,0 @@
{{ define "meta" }}<title>Forgot Password</title>{{ end }}
{{ define "content" }}
<div class="box is-shadowless">
<div class="content has-text-centered" style="padding: 0 40px;">
<h1 class="title has-text-dark">Reset Password</h1>
<form action="" method="POST" class="control">
<div class="field">
<label for="email" class="label is-hidden">Email Address</label>
<div class="control has-icons-left">
<input class="input is-medium is-shadowless" id="email" name="email" spellcheck="false" label="false" type="email" placeholder="Email Address" value="" autofocus />
<span class="icon is-medium is-left">
<i class="fas fa-at"></i>
</span>
</div>
</div>
{{ .csrfField }}
<div class="field is-grouped-right">
<input class="button is-success is-fullwidth is-medium" type="submit" value="Reset my Password">
</div>
</form>
</div>
</div>
{{ end }}

View file

@ -1,32 +0,0 @@
{{ define "meta" }}<title>Log In</title>{{ end }}
{{ define "content" }}
<div class="box is-shadowless">
<div class="content has-text-centered" style="padding: 0 40px;">
<h1 class="title has-text-dark">Sign Up</h1>
<form action="" method="POST" class="control">
<div class="field">
<label for="email" class="label is-hidden">Email</label>
<div class="control has-icons-left">
<input class="input is-medium is-shadowless" id="email" name="email" type="email" placeholder="Email Address" value=""/>
<span class="icon is-medium is-left">
<i class="fas fa-at"></i>
</span>
</div>
</div>
{{ .csrfField }}
<div class="field is-grouped-right">
<input class="button is-success is-fullwidth is-medium" type="submit" value="Sign Up">
</div>
<div class="field has-text-centered">
<p>By signing up you agree to the <a href="{{ url "legal/tos" }}" class="has-text-weight-bold">Terms of Service</a></p>
</div>
</form>
</div>
</div>
<div class="field">
<p class="has-text-white has-text-centered">
Already have an account? <a class="has-text-weight-bold" href="/login">Log In</a>
</p>
</div>
{{ end }}

View file

@ -1,179 +1,75 @@
package handlers package handlers
import ( import (
"bytes" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"time" "os"
"git.klink.asia/paul/certman/views" "git.klink.asia/paul/certman/views"
"golang.org/x/oauth2"
"github.com/go-chi/chi"
"github.com/gorilla/securecookie"
"git.klink.asia/paul/certman/services" "git.klink.asia/paul/certman/services"
"git.klink.asia/paul/certman/models"
) )
func RegisterHandler(p *services.Provider) http.HandlerFunc { func OAuth2Endpoint(p *services.Provider, config *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
// Get parameters
email := req.Form.Get("email")
user := models.User{}
user.Email = email
// don't set a password, user will get password reset request via mail
user.HashedPassword = []byte{}
err := p.DB.CreateUser(&user)
if err != nil {
panic(err.Error)
}
if err := createPasswordReset(p, &user); err != nil {
p.Sessions.Flash(w, req,
services.Flash{
Type: "danger",
Message: "The registration email could not be generated.",
},
)
http.Redirect(w, req, "/register", http.StatusFound)
}
p.Sessions.Flash(w, req,
services.Flash{
Type: "success",
Message: "The user was created. Check your inbox for the confirmation email.",
},
)
http.Redirect(w, req, "/login", http.StatusFound)
return
}
}
func LoginHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
// Get parameters
email := req.Form.Get("email")
password := req.Form.Get("password")
user, err := p.DB.GetUserByEmail(email)
if err != nil {
// could not find user
p.Sessions.Flash(
w, req, services.Flash{
Type: "warning", Message: "Invalid Email or Password.",
},
)
http.Redirect(w, req, "/login", http.StatusFound)
return
}
if !user.EmailValid {
p.Sessions.Flash(
w, req, services.Flash{
Type: "warning", Message: "You need to confirm your email before logging in.",
},
)
http.Redirect(w, req, "/login", http.StatusFound)
return
}
if err := user.CheckPassword(password); err != nil {
// wrong password
p.Sessions.Flash(
w, req, services.Flash{
Type: "warning", Message: "Invalid Email or Password.",
},
)
http.Redirect(w, req, "/login", http.StatusFound)
return
}
// user is logged in, set cookie
p.Sessions.SetUserEmail(w, req, email)
http.Redirect(w, req, "/certs", http.StatusSeeOther)
}
}
func ConfirmEmailHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
v := views.NewWithSession(req, p.Sessions) v := views.NewWithSession(req, p.Sessions)
switch req.Method { code := req.FormValue("code")
case "GET":
token := chi.URLParam(req, "token") // exchange code for token
pwr, err := p.DB.GetPasswordResetByToken(token) accessToken, err := config.Exchange(oauth2.NoContext, code)
_ = pwr
if err != nil { if err != nil {
v.RenderError(w, 404) fmt.Println(err)
return http.NotFound(w, req)
}
v.Render(w, "email-set-password")
case "POST":
password := req.Form.Get("password")
token := req.Form.Get("token")
pwr, err := p.DB.GetPasswordResetByToken(token)
if err != nil {
v.RenderError(w, 404)
return return
} }
user, err := p.DB.GetUserByID(pwr.UserID) 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 { if err != nil {
v.RenderError(w, 500) v.RenderError(w, http.StatusNotFound)
return return
} }
user.SetPassword(password) resp, err := httpClient.Do(apiRequest)
//err := p.DB.UpdateUser(user.ID, &user)
if err != nil { if err != nil {
v.RenderError(w, 500) fmt.Println(err.Error())
v.RenderError(w, http.StatusInternalServerError)
return return
} }
err = p.DB.DeletePasswordResetsByUserID(pwr.UserID) var user struct {
Username string `json:"username"`
default:
v.RenderError(w, 405)
} }
// try to get post params err = json.NewDecoder(resp.Body).Decode(&user)
if err != nil {
fmt.Println(err.Error())
v.RenderError(w, http.StatusInternalServerError)
return
}
fmt.Fprintln(w, "Okay.") 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 createPasswordReset(p *services.Provider, user *models.User) error { func GetLoginHandler(p *services.Provider, config *oauth2.Config) http.HandlerFunc {
// create the reset request return func(w http.ResponseWriter, req *http.Request) {
pwr := models.PasswordReset{ authURL := config.AuthCodeURL("", oauth2.AccessTypeOnline)
UserID: user.ID, http.Redirect(w, req, authURL, http.StatusFound)
Token: string(securecookie.GenerateRandomKey(32)),
ValidUntil: time.Now().Add(6 * time.Hour),
} }
if err := p.DB.CreatePasswordReset(&pwr); err != nil {
return err
}
var subject string
var text *bytes.Buffer
if user.EmailValid {
subject = "Password reset"
text.WriteString("Somebody (hopefully you) has requested a password reset.\nClick below to reset your password:\n")
} else {
// If the user email has not been confirmed yet, send out
// "mail confirmation"-mail instead
subject = "Email confirmation"
text.WriteString("Hello, thanks you for signing up!\nClick below to verify this email address\n")
}
return p.Email.Send(user.Email, subject, text.String(), "")
} }

View file

@ -1,41 +1,62 @@
package handlers package handlers
import ( import (
"bytes"
"crypto/ecdsa" "crypto/ecdsa"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"fmt" "fmt"
"html/template"
"io/ioutil" "io/ioutil"
"log" "log"
"math/big" "math/big"
"net/http" "net/http"
"strings"
"time" "time"
"git.klink.asia/paul/certman/models" "git.klink.asia/paul/certman/models"
"git.klink.asia/paul/certman/services" "git.klink.asia/paul/certman/services"
"github.com/go-chi/chi"
"git.klink.asia/paul/certman/views" "git.klink.asia/paul/certman/views"
) )
func ListCertHandler(p *services.Provider) http.HandlerFunc { func ListClientsHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
v := views.NewWithSession(req, p.Sessions) v := views.NewWithSession(req, p.Sessions)
v.Render(w, "cert_list")
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 { func CreateCertHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
email := p.Sessions.GetUserEmail(req) username := p.Sessions.GetUsername(req)
certname := req.FormValue("certname") certname := req.FormValue("certname")
user, err := p.DB.GetUserByEmail(email) // Validate certificate Name
if err != nil { if !IsByteLength(certname, 2, 64) || !IsDNSName(certname) {
fmt.Printf("Could not fetch user for mail %s\n", email) 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 // Load CA master certificate
caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key") caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key")
if err != nil { if err != nil {
@ -47,29 +68,46 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc {
key, err := rsa.GenerateKey(rand.Reader, 2048) key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {
log.Fatalf("Could not generate keypair: %s", err) 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 // Generate Certificate
derBytes, err := CreateCertificate(key, caCert, caKey) commonName := fmt.Sprintf("%s@%s", username, certname)
derBytes, err := CreateCertificate(commonName, key, caCert, caKey)
// Initialize new client config // Initialize new client config
client := models.Client{ client := models.Client{
Name: certname, Name: certname,
CreatedAt: time.Now(),
PrivateKey: x509.MarshalPKCS1PrivateKey(key), PrivateKey: x509.MarshalPKCS1PrivateKey(key),
Cert: derBytes, Cert: derBytes,
UserID: user.ID, User: username,
} }
// Insert client into database // Insert client into database
_ = client if err := p.ClientCollection.CreateClient(&client); err != nil {
//if err := p.DB.Create(&client).Error; err != nil { log.Println(err.Error())
// panic(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, p.Sessions.Flash(w, req,
services.Flash{ services.Flash{
Type: "success", Type: "success",
Message: "The certificate was created successfully.", Message: "The certificate was created successfully",
}, },
) )
@ -77,15 +115,77 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc {
} }
} }
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 { func DownloadCertHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
//v := views.New(req) v := views.New(req)
// // detemine own username
//derBytes, err := CreateCertificate(key, caCert, caKey) username := p.Sessions.GetUsername(req)
//pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) name := chi.URLParam(req, "name")
//
//pkBytes := x509.MarshalPKCS1PrivateKey(key) client, err := p.ClientCollection.GetClientByNameUser(name, username)
//pem.Encode(w, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkBytes}) 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 return
} }
} }
@ -100,10 +200,8 @@ func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateK
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
cpb, cr := pem.Decode(cf) cpb, _ := pem.Decode(cf)
fmt.Println(string(cr)) kpb, _ := pem.Decode(kf)
kpb, kr := pem.Decode(kf)
fmt.Println(string(kr))
crt, err := x509.ParseCertificate(cpb.Bytes) crt, err := x509.ParseCertificate(cpb.Bytes)
if err != nil { if err != nil {
@ -117,10 +215,10 @@ func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateK
} }
// CreateCertificate creates a CA-signed certificate // CreateCertificate creates a CA-signed certificate
func CreateCertificate(key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) { func CreateCertificate(commonName string, key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) {
subj := caCert.Subject subj := caCert.Subject
// .. except for the common name // .. except for the common name
subj.CommonName = "clientName" subj.CommonName = commonName
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
@ -132,11 +230,10 @@ func CreateCertificate(key interface{}, caCert *x509.Certificate, caKey interfac
SerialNumber: serialNumber, SerialNumber: serialNumber,
Subject: subj, Subject: subj,
NotBefore: time.Now(), NotBefore: time.Now().Add(-5 * time.Minute), // account for clock shift
NotAfter: time.Now().Add(24 * time.Hour * 356 * 5), NotAfter: time.Now().Add(24 * time.Hour * 356 * 5), // 5 years ought to be enough!
SignatureAlgorithm: x509.SHA256WithRSA, SignatureAlgorithm: x509.SHA256WithRSA,
//KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true, BasicConstraintsValid: true,
} }

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)
}

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,56 +1,53 @@
package main package main
import ( import (
"errors"
"log" "log"
"net/http" "net/http"
"os"
"time" "time"
"github.com/gorilla/securecookie"
"git.klink.asia/paul/certman/services" "git.klink.asia/paul/certman/services"
"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
_ "github.com/mattn/go-sqlite3"
) )
func main() { func main() {
log.Println("Initializing certman")
if err := checkCAFilesExist(); err != nil {
log.Fatalf("Could not read CA files: %s", err)
}
c := services.Config{ c := services.Config{
DB: &services.DBConfig{ CollectionPath: "./clients.json",
Type: "sqlite3",
DSN: "db.sqlite3",
Log: true,
},
Sessions: &services.SessionsConfig{ Sessions: &services.SessionsConfig{
SessionName: "_session", SessionName: "_session",
CookieKey: string(securecookie.GenerateRandomKey(32)), CookieKey: os.Getenv("APP_KEY"),
HttpOnly: true, HttpOnly: true,
Lifetime: 24 * time.Hour, Lifetime: 24 * time.Hour,
}, },
Email: &services.EmailConfig{
SMTPEnabled: false,
SMTPServer: "example.com",
SMTPPort: 25,
SMTPUsername: "test",
SMTPPassword: "password",
SMTPTimeout: 5 * time.Second,
From: "Mailtest <test@example.com>",
},
} }
log.Println(".. services")
serviceProvider := services.NewProvider(&c) serviceProvider := services.NewProvider(&c)
// Start the mail daemon, which re-uses connections to send mails to the
// SMTP server
go serviceProvider.Email.Daemon()
// load and parse template files // load and parse template files
log.Println(".. templates")
views.LoadTemplates() views.LoadTemplates()
mux := router.HandleRoutes(serviceProvider) 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

@ -11,7 +11,7 @@ import (
func RequireLogin(sessions *services.Sessions) func(http.Handler) http.Handler { func RequireLogin(sessions *services.Sessions) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, req *http.Request) { fn := func(w http.ResponseWriter, req *http.Request) {
if username := sessions.GetUserEmail(req); username == "" { if username := sessions.GetUsername(req); username == "" {
http.Redirect(w, req, "/login", http.StatusFound) http.Redirect(w, req, "/login", http.StatusFound)
} }

View file

@ -11,29 +11,21 @@ var (
ErrNotImplemented = errors.New("Not implemented") ErrNotImplemented = errors.New("Not implemented")
) )
// Model is a base model definition, including helpful fields for dealing with
// models in a database
type Model struct {
ID uint `gorm:"primary_key"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time `sql:"index"`
}
// Client represent the OpenVPN client configuration // Client represent the OpenVPN client configuration
type Client struct { type Client struct {
Model ID uint
CreatedAt time.Time
Name string Name string
User User User string
UserID uint
Cert []byte Cert []byte
PrivateKey []byte PrivateKey []byte
} }
type ClientProvider interface { type ClientProvider interface {
CountClients() (uint, error) CountClients() (uint, error)
CreateClient(*User) (*User, error) CreateClient(*Client) (*Client, error)
ListClients(count, offset int) ([]*User, error) ListClients(count, offset int) ([]*Client, error)
GetClientByID(id uint) (*User, error) ListClientsForUser(user string, count, offset int) ([]*Client, error)
GetClientByID(id uint) (*Client, error)
DeleteClient(id uint) error DeleteClient(id uint) error
} }

View file

@ -1,55 +0,0 @@
package models
import (
"time"
"golang.org/x/crypto/bcrypt"
)
// User represents a User of the system which is able to log in
type User struct {
Model
Email string
EmailValid bool
DisplayName 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 {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
u.HashedPassword = bytes
return nil
}
// 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 bcrypt.CompareHashAndPassword(u.HashedPassword, []byte(password))
}
type UserProvider interface {
CountUsers() (uint, error)
CreateUser(*User) error
ListUsers(count, offset int) ([]*User, error)
GetUserByID(id uint) (*User, error)
GetUserByEmail(email string) (*User, error)
DeleteUser(id uint) error
}
type PasswordReset struct {
Model
User *User
UserID uint
Token string
ValidUntil time.Time
}
type PasswordResetProvider interface {
CreatePasswordReset(*PasswordReset) error
GetPasswordResetByToken(token string) (*PasswordReset, error)
}

View file

@ -6,6 +6,7 @@ import (
"strings" "strings"
"git.klink.asia/paul/certman/services" "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"
@ -35,6 +36,18 @@ func HandleRoutes(provider *services.Provider) http.Handler {
mux.Use(mw.Recoverer) // recover on panic mux.Use(mw.Recoverer) // recover on panic
mux.Use(provider.Sessions.Manager.Use) // use session storage 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 // this either means we use the embedded files, or live-load
// from the file system (if `--tags="dev"` is used). // from the file system (if `--tags="dev"` is used).
@ -51,35 +64,22 @@ func HandleRoutes(provider *services.Provider) http.Handler {
)) ))
} }
r.HandleFunc("/", v("debug")) r.HandleFunc("/", http.RedirectHandler("certs", http.StatusFound).ServeHTTP)
r.Route("/register", func(r chi.Router) {
r.Get("/", v("register"))
r.Post("/", handlers.RegisterHandler(provider))
})
r.Route("/login", func(r chi.Router) { r.Route("/login", func(r chi.Router) {
r.Get("/", v("login")) r.Get("/", handlers.GetLoginHandler(provider, oauth2Config))
r.Post("/", handlers.LoginHandler(provider)) r.Get("/oauth2/redirect", handlers.OAuth2Endpoint(provider, oauth2Config))
})
r.Post("/confirm-email/{token}", handlers.ConfirmEmailHandler(provider))
r.Route("/forgot-password", func(r chi.Router) {
r.Get("/", v("forgot-password"))
r.Post("/", handlers.LoginHandler(provider))
}) })
r.Route("/certs", func(r chi.Router) { r.Route("/certs", func(r chi.Router) {
r.Use(mw.RequireLogin(provider.Sessions)) r.Use(mw.RequireLogin(provider.Sessions))
r.Get("/", handlers.ListCertHandler(provider)) r.Get("/", handlers.ListClientsHandler(provider))
r.Post("/new", handlers.CreateCertHandler(provider)) r.Post("/new", handlers.CreateCertHandler(provider))
r.HandleFunc("/download/{ID}", handlers.DownloadCertHandler(provider)) r.HandleFunc("/download/{name}", handlers.DownloadCertHandler(provider))
r.Post("/delete/{name}", handlers.DeleteCertHandler(provider))
}) })
r.HandleFunc("/500", func(w http.ResponseWriter, req *http.Request) { r.Get("/unconfigured-backend", handlers.NotFoundHandler)
panic("500")
})
}) })
// what should happen if no route matches // what should happen if no route matches

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)
}

View file

@ -1,106 +0,0 @@
package services
import (
"errors"
"log"
"git.klink.asia/paul/certman/models"
"github.com/jinzhu/gorm"
)
// Error Definitions
var (
ErrNotImplemented = errors.New("Not implemented")
)
type DBConfig struct {
Type string
DSN string
Log bool
}
// DB is a wrapper around gorm.DB to provide custom methods
type DB struct {
gorm *gorm.DB
conf *DBConfig
}
func NewDB(conf *DBConfig) *DB {
// Establish connection
db, err := gorm.Open(conf.Type, conf.DSN)
if err != nil {
log.Fatalf("Could not open database: %s", err.Error())
}
// Migrate models
db.AutoMigrate(models.User{}, models.Client{})
db.LogMode(conf.Log)
return &DB{
gorm: db,
conf: conf,
}
}
// CountUsers returns the number of Users in the datastore
func (db *DB) CountUsers() (uint, error) {
var count uint
err := db.gorm.Find(&models.User{}).Count(&count).Error
return count, err
}
// CreateUser inserts a user into the datastore
func (db *DB) CreateUser(user *models.User) error {
err := db.gorm.Create(&user).Error
return err
}
// ListUsers returns a slice of 'count' users, starting at 'offset'
func (db *DB) ListUsers(count, offset int) ([]*models.User, error) {
var users = make([]*models.User, 0)
err := db.gorm.Find(&users).Limit(count).Offset(offset).Error
return users, err
}
// GetUserByID returns a single user by ID
func (db *DB) GetUserByID(id uint) (*models.User, error) {
var user models.User
err := db.gorm.Where("id = ?", id).First(&user).Error
return &user, err
}
// GetUserByEmail returns a single user by email
func (db *DB) GetUserByEmail(email string) (*models.User, error) {
var user models.User
err := db.gorm.Where("email = ?", email).First(&user).Error
return &user, err
}
// DeleteUser removes a user from the datastore
func (db *DB) DeleteUser(id uint) error {
var user models.User
err := db.gorm.Where("id = ?", id).Delete(&user).Error
return err
}
// CreatePasswordReset creates a new password reset token
func (db *DB) CreatePasswordReset(pwReset *models.PasswordReset) error {
err := db.gorm.Create(&pwReset).Error
return err
}
// GetPasswordResetByToken retrieves a PasswordReset by token
func (db *DB) GetPasswordResetByToken(token string) (*models.PasswordReset, error) {
var pwReset models.PasswordReset
err := db.gorm.Where("token = ?", token).First(&pwReset).Error
return &pwReset, err
}
// DeletePasswordResetsByUserID deletes all pending password resets for a user
func (db *DB) DeletePasswordResetsByUserID(uid uint) error {
err := db.gorm.Where("user_id = ?", uid).Delete(&models.PasswordReset{}).Error
return err
}

View file

@ -1,121 +0,0 @@
package services
import (
"errors"
"log"
"time"
"github.com/go-mail/mail"
)
var (
ErrMailUninitializedConfig = errors.New("Mail: uninitialized config")
)
type EmailConfig struct {
From string
SMTPEnabled bool
SMTPServer string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPTimeout time.Duration
}
type Email struct {
config *EmailConfig
mailChan chan *mail.Message
}
func NewEmail(conf *EmailConfig) *Email {
if conf == nil {
log.Println(ErrMailUninitializedConfig)
}
return &Email{
config: conf,
mailChan: make(chan *mail.Message, 4),
}
}
// Send sends an email to the receiver
func (email *Email) Send(to, subject, text, html string) error {
if email.config == nil {
log.Print("Error: trying to send mail with uninitialized config.")
return ErrMailUninitializedConfig
}
if !email.config.SMTPEnabled {
log.Printf("SMTP is disabled in config, printing out email text instead:\nTo: %s\n%s", to, text)
}
m := mail.NewMessage()
m.SetHeader("From", email.config.From)
m.SetHeader("To", to)
m.SetHeader("Subject", subject)
m.SetBody("text/plain", text)
if len(html) > 0 {
m.AddAlternative("text/html", html)
}
// put email in chan
email.mailChan <- m
return nil
}
// Daemon is a function that takes Mail and sends it without blocking.
// WIP
func (email *Email) Daemon() {
if email.config == nil {
log.Print("Error: trying to set up mail deamon with uninitialized config.")
return
}
if !email.config.SMTPEnabled {
log.Print("SMTP is disabled in config, emails will be printed instead.")
return
}
log.Print("Running mail sending routine")
d := mail.NewDialer(
email.config.SMTPServer,
email.config.SMTPPort,
email.config.SMTPUsername,
email.config.SMTPPassword)
var s mail.SendCloser
var err error
open := false
for {
select {
case m, ok := <-email.mailChan:
if !ok {
// channel is closed
log.Print("Channel closed")
return
}
if !open {
if s, err = d.Dial(); err != nil {
log.Print(err)
return
}
open = true
}
log.Printf("Trying to send mail")
if err := mail.Send(s, m); err != nil {
log.Printf("Mail: %s", err)
}
// Close the connection if no email was sent in the last X seconds.
case <-time.After(email.config.SMTPTimeout):
if open {
if err := s.Close(); err != nil {
log.Printf("Mail: Failed to close connection: %s", err)
}
open = false
}
}
}
}

View file

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

View file

@ -49,10 +49,10 @@ func NewSessions(conf *SessionsConfig) *Sessions {
return &Sessions{store} return &Sessions{store}
} }
func (store *Sessions) GetUserEmail(req *http.Request) string { func (store *Sessions) GetUsername(req *http.Request) string {
if store == nil { if store == nil {
// if store was not initialized, all requests fail // if store was not initialized, all requests fail
log.Println("Zero pointer when checking session for username") log.Println("Nil pointer when checking session for username")
return "" return ""
} }
@ -69,7 +69,7 @@ func (store *Sessions) GetUserEmail(req *http.Request) string {
return email return email
} }
func (store *Sessions) SetUserEmail(w http.ResponseWriter, req *http.Request, email string) { func (store *Sessions) SetUsername(w http.ResponseWriter, req *http.Request, username string) {
if store == nil { if store == nil {
// if store was not initialized, do nothing // if store was not initialized, do nothing
return return
@ -80,7 +80,7 @@ func (store *Sessions) SetUserEmail(w http.ResponseWriter, req *http.Request, em
// renew token to avoid session pinning/fixation attack // renew token to avoid session pinning/fixation attack
sess.RenewToken(w) sess.RenewToken(w)
sess.PutString(w, UserEmailKey, email) sess.PutString(w, UserEmailKey, username)
} }

View file

@ -23,11 +23,10 @@ func LoadTemplates() {
"500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"), "500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"),
"login": newTemplate("layouts/auth.gohtml", "views/login.gohtml"), "login": newTemplate("layouts/auth.gohtml", "views/login.gohtml"),
"register": newTemplate("layouts/auth.gohtml", "views/register.gohtml"),
"forgot-password": newTemplate("layouts/auth.gohtml", "views/forgot-password.gohtml"),
"debug": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/debug.gohtml"), "client_list": newTemplate("layouts/application.gohtml", "views/client_list.gohtml"),
"cert_list": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/cert_list.gohtml"),
"config.ovpn": newTemplate("files/config.ovpn"),
} }
return return
} }

View file

@ -46,7 +46,7 @@ func NewWithSession(req *http.Request, sessionStore *services.Sessions) *View {
"Env": "develop", "Env": "develop",
}, },
"flashes": []services.Flash{}, "flashes": []services.Flash{},
"username": sessionStore.GetUserEmail(req), "username": sessionStore.GetUsername(req),
}, },
} }
} }