diff --git a/.gitignore b/.gitignore
index 6cd70e1..39b0ac5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
*_vfsdata.go
certman
db.sqlite3
+*.crt
+*.key
+clients.json
+.env
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index e32802b..17f665b 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -15,61 +15,76 @@ before_script:
- cd $GOPATH/src/$REPO_NAME
stages:
- - test
- - build
- - release
+ - test
+ - build
+ - release
format:
- stage: test
- tags:
- - docker
- script:
- # we use tags="dev" so there is no dependency on the prebuilt assets yet
- - go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies
- - go fmt $(go list ./... | grep -v /vendor/)
- - go vet $(go list ./... | grep -v /vendor/)
- - go test -tags="dev" -race $(go list ./... | grep -v /vendor/) -v -coverprofile .testCoverage.txt
- # Use coverage parsing regex: ^coverage:\s(\d+(?:\.\d+)?%)
+ stage: test
+ tags:
+ - docker
+ script:
+ # we use tags="dev" so there is no dependency on the prebuilt assets yet
+ - go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies
+ - go fmt $(go list ./... | grep -v /vendor/)
+ - go vet $(go list ./... | grep -v /vendor/)
+ - go test -tags="dev" -race $(go list ./... | grep -v /vendor/) -v -coverprofile .testCoverage.txt
+ # Use coverage parsing regex: ^coverage:\s(\d+(?:\.\d+)?%)
compile:
- stage: build
- tags:
- - docker
- script:
- # we use tags="dev" so there is no dependency on the prebuilt assets yet
- - go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies
+ stage: build
+ tags:
+ - docker
+ script:
+ # we use tags="dev" so there is no dependency on the prebuilt assets yet
+ - go get -tags="dev" -v $(go list ./... | grep -v /vendor/) # get missing dependencies
- # generate assets
- - go get github.com/shurcooL/vfsgen/cmd/vfsgendev
- - go generate git.klink.asia/paul/certman/assets
+ # generate assets
+ - go get github.com/shurcooL/vfsgen/cmd/vfsgendev
+ - go generate git.klink.asia/paul/certman/assets
- # build binaries -- list of supported plattforms is here:
- # https://stackoverflow.com/a/20728862
- - GOOS=linux GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman
- #- GOOS=linux GOARCH=arm GOARM=6 go build -o $CI_PROJECT_DIR/certman.arm
- #- GOOS=windows GOARCH=amd64 go build -o $CI_PROJECT_DIR/certman.exe
- artifacts:
- expire_in: "8 hrs"
- paths:
- - certman
- # - certman.arm
- # - certman.exe
+ # build binaries -- list of supported plattforms is here:
+ # https://stackoverflow.com/a/20728862
+ - GOOS=linux GOARCH=amd64 go build -tags "netgo" -o $CI_PROJECT_DIR/certman
+ - GOOS=linux GOARCH=arm GOARM=6 go build -tags "netgo" -o $CI_PROJECT_DIR/certman.arm
+ - GOOS=windows GOARCH=amd64 go build -tags "netgo" -o $CI_PROJECT_DIR/certman.exe
+ artifacts:
+ expire_in: "8 hrs"
+ paths:
+ - certman
+ - certman.arm
+ - certman.exe
minify:
- stage: release
- tags:
- - docker
- dependencies:
- - compile
- image:
- name: znly/upx:latest
- entrypoint: ["/bin/sh", "-c"]
- script:
- - upx --best --brute $CI_PROJECT_DIR/certman certman.arm certman.exe
- artifacts:
- paths:
- - certman
- #- certman.arm
- #- certman.exe
- only:
- - tags
+ stage: release
+ tags:
+ - docker
+ dependencies:
+ - compile
+ image:
+ name: znly/upx:latest
+ entrypoint: ["/bin/sh", "-c"]
+ script:
+ - upx --best --brute $CI_PROJECT_DIR/certman $CI_PROJECT_DIR/certman.arm $CI_PROJECT_DIR/certman.exe
+ artifacts:
+ paths:
+ - certman
+ - certman.arm
+ - certman.exe
+ only:
+ - 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
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..1d0f529
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0563ca0
--- /dev/null
+++ b/README.md
@@ -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`
\ No newline at end of file
diff --git a/assets/templates/files/config.ovpn b/assets/templates/files/config.ovpn
new file mode 100644
index 0000000..f57f6d0
--- /dev/null
+++ b/assets/templates/files/config.ovpn
@@ -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
+
+
+-----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-----
+
+
+
+{{ .Cert | html }}
+
+
+{{ .Key | html }}
+
+
+#
+# 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-----
+
+{{ end }}
diff --git a/assets/templates/layouts/admin.gohtml b/assets/templates/layouts/admin.gohtml
deleted file mode 100644
index a6d2c64..0000000
--- a/assets/templates/layouts/admin.gohtml
+++ /dev/null
@@ -1,39 +0,0 @@
-{{ define "base" }}
-
-
-
-
-
-
-
-
-
-
- Admin
-
-
-
-
- {{ template "header" . }}
-
-
- {{range .flashes}}
-
- {{end}}
-
-
-
- {{ template "content" . }}
-
-
- {{ template "footer" . }}
- {{ template "sink" .}}
-
-
-
-{{ end }}
-
-{{ define "header"}}{{end}}
-{{ define "content"}}{{end}}
-{{ define "footer"}}{{end}}
-{{ define "sink"}}{{end}}
\ No newline at end of file
diff --git a/assets/templates/layouts/application.gohtml b/assets/templates/layouts/application.gohtml
index 7a231d9..573c397 100644
--- a/assets/templates/layouts/application.gohtml
+++ b/assets/templates/layouts/application.gohtml
@@ -28,14 +28,11 @@
{{end}}
{{ end }}
- {{ template "header" . }}
{{ template "content" . }}
- {{ template "footer" . }}
- {{ template "sink" .}}
@@ -45,7 +42,4 @@
{{ end }}
{{ define "meta"}}{{end}}
-{{ define "header"}}{{end}}
-{{ define "content"}}{{end}}
-{{ define "footer"}}{{end}}
-{{ define "sink"}}{{end}}
\ No newline at end of file
+{{ define "content"}}{{end}}
\ No newline at end of file
diff --git a/assets/templates/shared/footer.gohtml b/assets/templates/shared/footer.gohtml
deleted file mode 100644
index 8e248f6..0000000
--- a/assets/templates/shared/footer.gohtml
+++ /dev/null
@@ -1,13 +0,0 @@
-{{ define "footer" }}
-
-{{ end }}
\ No newline at end of file
diff --git a/assets/templates/shared/header.gohtml b/assets/templates/shared/header.gohtml
deleted file mode 100644
index 5d3cbe7..0000000
--- a/assets/templates/shared/header.gohtml
+++ /dev/null
@@ -1,37 +0,0 @@
-{{ define "header" }}
-
-
-
-
-
-
- Certificate management
-
-
- Generate a VPN configuration
-
-
-
-
-
-
-
-
-
-
-{{ end }}
\ No newline at end of file
diff --git a/assets/templates/views/cert_list.gohtml b/assets/templates/views/cert_list.gohtml
deleted file mode 100644
index f667653..0000000
--- a/assets/templates/views/cert_list.gohtml
+++ /dev/null
@@ -1,24 +0,0 @@
-{{ define "meta" }}
- Log in
-{{ end}}
-
-{{ define "content" }}
-
-
-
-
-
-
Certificates
- {{ if .Certificates }}
- {{ range .Certificates }}
-
{{ .User }}@{{ .Name }}
- {{ end }}
- {{ else }}
-
You don't have certificates yet!
- {{ end }}
-
-
-
-
-
-{{ end}}
diff --git a/assets/templates/views/client_list.gohtml b/assets/templates/views/client_list.gohtml
new file mode 100644
index 0000000..57eda64
--- /dev/null
+++ b/assets/templates/views/client_list.gohtml
@@ -0,0 +1,68 @@
+{{ define "meta" }}
+ Certificate List
+{{ end}}
+
+{{ define "content" }}
+
+
+
+
+
+
Certificates for {{ .username }}:
+
+
+
+ Device |
+ Created |
+ Actions |
+
+
+
+
+
+ {{ range .Clients }}
+
+ {{ .User }}@{{ .Name }} |
+ |
+
+
+ |
+
+ {{ end }}
+
+
+
+
+
+
+
+
+{{ end}}
diff --git a/assets/templates/views/debug.gohtml b/assets/templates/views/debug.gohtml
deleted file mode 100644
index 4ca31c2..0000000
--- a/assets/templates/views/debug.gohtml
+++ /dev/null
@@ -1,17 +0,0 @@
-{{ define "meta" }}
- Landing Page
-
-{{ end}}
-
-{{ define "content" }}
-
-
-
-
-
Hello, World!
-
Have some variables: {{.}}
-
-
-
-
-{{ end}}
diff --git a/assets/templates/views/forgot-password.gohtml b/assets/templates/views/forgot-password.gohtml
deleted file mode 100644
index 2996737..0000000
--- a/assets/templates/views/forgot-password.gohtml
+++ /dev/null
@@ -1,24 +0,0 @@
-{{ define "meta" }}Forgot Password{{ end }}
-
-{{ define "content" }}
-
-{{ end }}
\ No newline at end of file
diff --git a/assets/templates/views/register.gohtml b/assets/templates/views/register.gohtml
deleted file mode 100644
index 149e1de..0000000
--- a/assets/templates/views/register.gohtml
+++ /dev/null
@@ -1,32 +0,0 @@
-{{ define "meta" }}Log In{{ end }}
-
-{{ define "content" }}
-
-
-
- Already have an account? Log In
-
-
-{{ end }}
\ No newline at end of file
diff --git a/handlers/auth.go b/handlers/auth.go
index 504b27c..5f1c7dc 100644
--- a/handlers/auth.go
+++ b/handlers/auth.go
@@ -1,179 +1,75 @@
package handlers
import (
- "bytes"
+ "encoding/json"
"fmt"
"net/http"
- "time"
+ "os"
"git.klink.asia/paul/certman/views"
-
- "github.com/go-chi/chi"
-
- "github.com/gorilla/securecookie"
+ "golang.org/x/oauth2"
"git.klink.asia/paul/certman/services"
-
- "git.klink.asia/paul/certman/models"
)
-func RegisterHandler(p *services.Provider) 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 {
+func OAuth2Endpoint(p *services.Provider, config *oauth2.Config) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
v := views.NewWithSession(req, p.Sessions)
- switch req.Method {
- case "GET":
- token := chi.URLParam(req, "token")
- pwr, err := p.DB.GetPasswordResetByToken(token)
- _ = pwr
- if err != nil {
- v.RenderError(w, 404)
- return
- }
- 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
- }
+ code := req.FormValue("code")
- user, err := p.DB.GetUserByID(pwr.UserID)
- if err != nil {
- v.RenderError(w, 500)
- return
- }
-
- user.SetPassword(password)
-
- //err := p.DB.UpdateUser(user.ID, &user)
- if err != nil {
- v.RenderError(w, 500)
- return
- }
-
- err = p.DB.DeletePasswordResetsByUserID(pwr.UserID)
-
- default:
- v.RenderError(w, 405)
+ // exchange code for token
+ accessToken, err := config.Exchange(oauth2.NoContext, code)
+ if err != nil {
+ fmt.Println(err)
+ http.NotFound(w, req)
+ return
}
- // try to get post params
+ if accessToken.Valid() {
+ // generate a client using the access token
+ httpClient := config.Client(oauth2.NoContext, accessToken)
- fmt.Fprintln(w, "Okay.")
+ 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 createPasswordReset(p *services.Provider, user *models.User) error {
- // create the reset request
- pwr := models.PasswordReset{
- UserID: user.ID,
- Token: string(securecookie.GenerateRandomKey(32)),
- ValidUntil: time.Now().Add(6 * time.Hour),
+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)
}
-
- 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(), "")
}
diff --git a/handlers/cert.go b/handlers/cert.go
index 5900289..94cd859 100644
--- a/handlers/cert.go
+++ b/handlers/cert.go
@@ -1,41 +1,62 @@
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 ListCertHandler(p *services.Provider) http.HandlerFunc {
+func ListClientsHandler(p *services.Provider) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
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 {
return func(w http.ResponseWriter, req *http.Request) {
- email := p.Sessions.GetUserEmail(req)
+ username := p.Sessions.GetUsername(req)
certname := req.FormValue("certname")
- user, err := p.DB.GetUserByEmail(email)
- if err != nil {
- fmt.Printf("Could not fetch user for mail %s\n", email)
+ // 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 {
@@ -47,29 +68,46 @@ func CreateCertHandler(p *services.Provider) http.HandlerFunc {
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
- derBytes, err := CreateCertificate(key, caCert, caKey)
+ 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,
- UserID: user.ID,
+ User: username,
}
// Insert client into database
- _ = client
- //if err := p.DB.Create(&client).Error; err != nil {
- // panic(err.Error())
- //}
+ 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.",
+ 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 %s", 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)
- //
- //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})
+ 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
}
}
@@ -100,10 +200,8 @@ func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateK
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))
+ cpb, _ := pem.Decode(cf)
+ kpb, _ := pem.Decode(kf)
crt, err := x509.ParseCertificate(cpb.Bytes)
if err != nil {
@@ -117,10 +215,10 @@ func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateK
}
// 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
// .. except for the common name
- subj.CommonName = "clientName"
+ subj.CommonName = commonName
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
@@ -132,11 +230,10 @@ func CreateCertificate(key interface{}, caCert *x509.Certificate, caKey interfac
SerialNumber: serialNumber,
Subject: subj,
- NotBefore: time.Now(),
- NotAfter: time.Now().Add(24 * time.Hour * 356 * 5),
+ 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,
- //KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment,
+ SignatureAlgorithm: x509.SHA256WithRSA,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
diff --git a/handlers/converters.go b/handlers/converters.go
new file mode 100644
index 0000000..d5809f3
--- /dev/null
+++ b/handlers/converters.go
@@ -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)
+}
diff --git a/handlers/validators.go b/handlers/validators.go
new file mode 100644
index 0000000..1ee013b
--- /dev/null
+++ b/handlers/validators.go
@@ -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
+}
diff --git a/main.go b/main.go
index 872ccea..eb74fc6 100644
--- a/main.go
+++ b/main.go
@@ -1,56 +1,53 @@
package main
import (
+ "errors"
"log"
"net/http"
+ "os"
"time"
- "github.com/gorilla/securecookie"
-
"git.klink.asia/paul/certman/services"
"git.klink.asia/paul/certman/router"
"git.klink.asia/paul/certman/views"
-
- // import sqlite3 driver
- _ "github.com/mattn/go-sqlite3"
)
func main() {
+ log.Println("Initializing certman")
+ if err := checkCAFilesExist(); err != nil {
+ log.Fatalf("Could not read CA files: %s", err)
+ }
+
c := services.Config{
- DB: &services.DBConfig{
- Type: "sqlite3",
- DSN: "db.sqlite3",
- Log: true,
- },
+ CollectionPath: "./clients.json",
Sessions: &services.SessionsConfig{
SessionName: "_session",
- CookieKey: string(securecookie.GenerateRandomKey(32)),
+ CookieKey: os.Getenv("APP_KEY"),
HttpOnly: true,
Lifetime: 24 * time.Hour,
},
- Email: &services.EmailConfig{
- SMTPEnabled: false,
- SMTPServer: "example.com",
- SMTPPort: 25,
- SMTPUsername: "test",
- SMTPPassword: "password",
- SMTPTimeout: 5 * time.Second,
- From: "Mailtest ",
- },
}
+ log.Println(".. services")
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
+ log.Println(".. templates")
views.LoadTemplates()
mux := router.HandleRoutes(serviceProvider)
- err := http.ListenAndServe(":8000", mux)
+ log.Println(".. server")
+ err := http.ListenAndServe(os.Getenv("APP_LISTEN"), mux)
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
+}
diff --git a/middleware/requirelogin.go b/middleware/requirelogin.go
index 98bbac7..1f18f30 100644
--- a/middleware/requirelogin.go
+++ b/middleware/requirelogin.go
@@ -11,7 +11,7 @@ import (
func RequireLogin(sessions *services.Sessions) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
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)
}
diff --git a/models/model.go b/models/model.go
index 68c2719..01095ce 100644
--- a/models/model.go
+++ b/models/model.go
@@ -11,29 +11,21 @@ var (
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
type Client struct {
- Model
+ ID uint
+ CreatedAt time.Time
Name string
- User User
- UserID uint
+ User string
Cert []byte
PrivateKey []byte
}
type ClientProvider interface {
CountClients() (uint, error)
- CreateClient(*User) (*User, error)
- ListClients(count, offset int) ([]*User, error)
- GetClientByID(id uint) (*User, 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
}
diff --git a/models/user.go b/models/user.go
deleted file mode 100644
index 467fd15..0000000
--- a/models/user.go
+++ /dev/null
@@ -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)
-}
diff --git a/router/router.go b/router/router.go
index c9c1a64..36dbfc2 100644
--- a/router/router.go
+++ b/router/router.go
@@ -6,6 +6,7 @@ import (
"strings"
"git.klink.asia/paul/certman/services"
+ "golang.org/x/oauth2"
"git.klink.asia/paul/certman/assets"
"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(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
// this either means we use the embedded files, or live-load
// 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.Route("/register", func(r chi.Router) {
- r.Get("/", v("register"))
- r.Post("/", handlers.RegisterHandler(provider))
- })
+ r.HandleFunc("/", http.RedirectHandler("certs", http.StatusFound).ServeHTTP)
r.Route("/login", func(r chi.Router) {
- r.Get("/", v("login"))
- r.Post("/", handlers.LoginHandler(provider))
- })
-
- 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.Get("/", handlers.GetLoginHandler(provider, oauth2Config))
+ r.Get("/oauth2/redirect", handlers.OAuth2Endpoint(provider, oauth2Config))
})
r.Route("/certs", func(r chi.Router) {
r.Use(mw.RequireLogin(provider.Sessions))
- r.Get("/", handlers.ListCertHandler(provider))
+ r.Get("/", handlers.ListClientsHandler(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) {
- panic("500")
- })
+ r.Get("/unconfigured-backend", handlers.NotFoundHandler)
})
// what should happen if no route matches
diff --git a/services/clientstore.go b/services/clientstore.go
new file mode 100644
index 0000000..3f47c67
--- /dev/null
+++ b/services/clientstore.go
@@ -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)
+}
diff --git a/services/db.go b/services/db.go
deleted file mode 100644
index b0c26bd..0000000
--- a/services/db.go
+++ /dev/null
@@ -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
-}
diff --git a/services/email.go b/services/email.go
deleted file mode 100644
index baea8d9..0000000
--- a/services/email.go
+++ /dev/null
@@ -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
- }
- }
- }
-}
diff --git a/services/provider.go b/services/provider.go
index ab2abaa..f52fcd9 100644
--- a/services/provider.go
+++ b/services/provider.go
@@ -1,24 +1,21 @@
package services
type Config struct {
- DB *DBConfig
- Sessions *SessionsConfig
- Email *EmailConfig
+ CollectionPath string
+ Sessions *SessionsConfig
}
type Provider struct {
- DB *DB
- Sessions *Sessions
- Email *Email
+ ClientCollection *ClientCollection
+ Sessions *Sessions
}
// NewProvider returns the ServiceProvider
func NewProvider(conf *Config) *Provider {
var provider = &Provider{}
- provider.DB = NewDB(conf.DB)
+ provider.ClientCollection = NewClientCollection(conf.CollectionPath)
provider.Sessions = NewSessions(conf.Sessions)
- provider.Email = NewEmail(conf.Email)
return provider
}
diff --git a/services/sessions.go b/services/sessions.go
index 36ee58b..83da679 100644
--- a/services/sessions.go
+++ b/services/sessions.go
@@ -49,10 +49,10 @@ func NewSessions(conf *SessionsConfig) *Sessions {
return &Sessions{store}
}
-func (store *Sessions) GetUserEmail(req *http.Request) string {
+func (store *Sessions) GetUsername(req *http.Request) string {
if store == nil {
// 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 ""
}
@@ -69,7 +69,7 @@ func (store *Sessions) GetUserEmail(req *http.Request) string {
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 was not initialized, do nothing
return
@@ -80,7 +80,7 @@ func (store *Sessions) SetUserEmail(w http.ResponseWriter, req *http.Request, em
// renew token to avoid session pinning/fixation attack
sess.RenewToken(w)
- sess.PutString(w, UserEmailKey, email)
+ sess.PutString(w, UserEmailKey, username)
}
diff --git a/views/templates.go b/views/templates.go
index 9d000ff..ec1655d 100644
--- a/views/templates.go
+++ b/views/templates.go
@@ -22,12 +22,11 @@ func LoadTemplates() {
"404": newTemplate("layouts/application.gohtml", "errors/404.gohtml"),
"500": newTemplate("layouts/application.gohtml", "errors/500.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"),
+ "login": newTemplate("layouts/auth.gohtml", "views/login.gohtml"),
- "debug": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/debug.gohtml"),
- "cert_list": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/cert_list.gohtml"),
+ "client_list": newTemplate("layouts/application.gohtml", "views/client_list.gohtml"),
+
+ "config.ovpn": newTemplate("files/config.ovpn"),
}
return
}
diff --git a/views/views.go b/views/views.go
index c0e33e7..f5185e6 100644
--- a/views/views.go
+++ b/views/views.go
@@ -46,7 +46,7 @@ func NewWithSession(req *http.Request, sessionStore *services.Sessions) *View {
"Env": "develop",
},
"flashes": []services.Flash{},
- "username": sessionStore.GetUserEmail(req),
+ "username": sessionStore.GetUsername(req),
},
}
}