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 7231f45..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/static/img/logo.png b/assets/static/img/logo.png deleted file mode 100644 index a016c2b..0000000 Binary files a/assets/static/img/logo.png and /dev/null differ diff --git a/assets/static/img/logo.svg b/assets/static/img/logo.svg new file mode 100644 index 0000000..cd6e87b --- /dev/null +++ b/assets/static/img/logo.svg @@ -0,0 +1,59 @@ + +image/svg+xmlONEOFFONEOFFCreated with Sketch. \ No newline at end of file diff --git a/assets/templates/errors/403.gohtml b/assets/templates/errors/403.gohtml new file mode 100644 index 0000000..10b4e81 --- /dev/null +++ b/assets/templates/errors/403.gohtml @@ -0,0 +1,21 @@ +{{ define "meta" }} + Forbidden +{{ end }} + +{{ define "content" }} +
+
+
+
+
+

Forbidden

+

+ The received the request was not processed. + For example, the CSRF validation may have failed. +

+
+
+
+
+
+{{ end }} \ 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 3e00c6e..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 abf3795..573c397 100644 --- a/assets/templates/layouts/application.gohtml +++ b/assets/templates/layouts/application.gohtml @@ -6,41 +6,40 @@ - + - - + + {{ template "meta" . }} {{ if eq .Meta.Env "production" }} {{ end }} + - {{ template "header" . }} + {{ if .flashes}}
{{range .flashes}} -
{{ .Message }}
+ {{ .Render }} {{end}}
+ {{ end }}
{{ template "content" . }}
- {{ template "footer" . }} - {{ template "sink" .}} - - + + {{ 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/layouts/auth.gohtml b/assets/templates/layouts/auth.gohtml new file mode 100644 index 0000000..8f2b65a --- /dev/null +++ b/assets/templates/layouts/auth.gohtml @@ -0,0 +1,56 @@ +{{ define "base" }} + + + + + + + + + + + + + {{ template "meta" . }} + {{ if eq .Meta.Env "production" }} + + {{ end }} + + + + +
+ {{ if .flashes}} +
+ {{range .flashes}} + {{ .Render }} + {{end}} +
+ {{ end }} +
+
+
+
+
+
+ +
+
+ {{ template "content" . }} +
+
+
+
+
+ + + + + + + +{{ end }} + +{{ define "meta"}}{{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 d3cfbd9..0000000 --- a/assets/templates/shared/header.gohtml +++ /dev/null @@ -1,25 +0,0 @@ -{{ define "header" }} - -{{ end }} \ No newline at end of file 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 }}:

+ + + + + + + + + + + + + + + {{ range .Clients }} + + + + + + {{ end }} + +
DeviceCreatedActions
+ + {{ .csrfField }}

{{ .User }}@{{ .Name }}

+
+

+ Download +

+
+
+ {{ $.csrfField }} + +
+
+
+
+ +
+
+
+
+
+{{ 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/login.gohtml b/assets/templates/views/login.gohtml index 5218dec..7dcd655 100644 --- a/assets/templates/views/login.gohtml +++ b/assets/templates/views/login.gohtml @@ -1,24 +1,41 @@ -{{ define "meta" }} - Log in - -{{ end}} +{{ define "meta" }}Log In{{ end }} {{ define "content" }} -
-
-
-
-
-

Hello, World!

-
- - - {{ .csrfField }} - -
+
+
+

Log In

+
+
+ +
+ + + +
-
+
+ +
+ + + + +
+
+ {{ .csrfField }} +
+ +
+ +
-
-{{ end}} +
+
+

+ Don't have an account? Sign Up +

+
+{{ end }} \ No newline at end of file diff --git a/handlers/auth.go b/handlers/auth.go new file mode 100644 index 0000000..5f1c7dc --- /dev/null +++ b/handlers/auth.go @@ -0,0 +1,75 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + + "git.klink.asia/paul/certman/views" + "golang.org/x/oauth2" + + "git.klink.asia/paul/certman/services" +) + +func OAuth2Endpoint(p *services.Provider, config *oauth2.Config) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + v := views.NewWithSession(req, p.Sessions) + + code := req.FormValue("code") + + // exchange code for token + accessToken, err := config.Exchange(oauth2.NoContext, code) + if err != nil { + fmt.Println(err) + http.NotFound(w, req) + return + } + + if accessToken.Valid() { + // generate a client using the access token + httpClient := config.Client(oauth2.NoContext, accessToken) + + apiRequest, err := http.NewRequest("GET", os.Getenv("USER_ENDPOINT"), nil) + if err != nil { + v.RenderError(w, http.StatusNotFound) + return + } + + resp, err := httpClient.Do(apiRequest) + if err != nil { + fmt.Println(err.Error()) + v.RenderError(w, http.StatusInternalServerError) + return + } + + var user struct { + Username string `json:"username"` + } + + err = json.NewDecoder(resp.Body).Decode(&user) + if err != nil { + fmt.Println(err.Error()) + v.RenderError(w, http.StatusInternalServerError) + return + } + + if user.Username != "" { + p.Sessions.SetUsername(w, req, user.Username) + http.Redirect(w, req, "/certs", http.StatusFound) + return + } + + fmt.Println(err.Error()) + v.RenderError(w, http.StatusInternalServerError) + return + } + } +} + +func GetLoginHandler(p *services.Provider, config *oauth2.Config) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + authURL := config.AuthCodeURL("", oauth2.AccessTypeOnline) + http.Redirect(w, req, authURL, http.StatusFound) + } +} diff --git a/handlers/cert.go b/handlers/cert.go new file mode 100644 index 0000000..94cd859 --- /dev/null +++ b/handlers/cert.go @@ -0,0 +1,253 @@ +package handlers + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "html/template" + "io/ioutil" + "log" + "math/big" + "net/http" + "strings" + "time" + + "git.klink.asia/paul/certman/models" + "git.klink.asia/paul/certman/services" + "github.com/go-chi/chi" + + "git.klink.asia/paul/certman/views" +) + +func ListClientsHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + v := views.NewWithSession(req, p.Sessions) + + username := p.Sessions.GetUsername(req) + + clients, _ := p.ClientCollection.ListClientsForUser(username) + + v.Vars["Clients"] = clients + v.Render(w, "client_list") + } +} + +func CreateCertHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + username := p.Sessions.GetUsername(req) + certname := req.FormValue("certname") + + // Validate certificate Name + if !IsByteLength(certname, 2, 64) || !IsDNSName(certname) { + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "The certificate name can only contain letters and numbers", + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + return + } + + // lowercase the certificate name, to avoid problems with the case + // insensitive matching inside OpenVPN + certname = strings.ToLower(certname) + + // Load CA master certificate + caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key") + if err != nil { + log.Fatalf("error loading ca keyfiles: %s", err) + panic(err.Error()) + } + + // Generate Keypair + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Fatalf("Could not generate keypair: %s", err) + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "The certificate key could not be generated", + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + return + } + + // Generate Certificate + commonName := fmt.Sprintf("%s@%s", username, certname) + derBytes, err := CreateCertificate(commonName, key, caCert, caKey) + + // Initialize new client config + client := models.Client{ + Name: certname, + CreatedAt: time.Now(), + PrivateKey: x509.MarshalPKCS1PrivateKey(key), + Cert: derBytes, + User: username, + } + + // Insert client into database + if err := p.ClientCollection.CreateClient(&client); err != nil { + log.Println(err.Error()) + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "The certificate could not be added to the database", + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + return + } + + p.Sessions.Flash(w, req, + services.Flash{ + Type: "success", + Message: "The certificate was created successfully", + }, + ) + + http.Redirect(w, req, "/certs", http.StatusFound) + } +} + +func DeleteCertHandler(p *services.Provider) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + v := views.New(req) + // detemine own username + username := p.Sessions.GetUsername(req) + name := chi.URLParam(req, "name") + + client, err := p.ClientCollection.GetClientByNameUser(name, username) + if err != nil { + v.RenderError(w, http.StatusNotFound) + return + } + + err = p.ClientCollection.DeleteClient(client.ID) + if err != nil { + p.Sessions.Flash(w, req, + services.Flash{ + Type: "danger", + Message: "Failed to delete certificate", + }, + ) + http.Redirect(w, req, "/certs", http.StatusFound) + } + + p.Sessions.Flash(w, req, + services.Flash{ + Type: "success", + Message: template.HTML(fmt.Sprintf("Successfully deleted client %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) + // detemine own username + username := p.Sessions.GetUsername(req) + name := chi.URLParam(req, "name") + + client, err := p.ClientCollection.GetClientByNameUser(name, username) + if err != nil { + v.RenderError(w, http.StatusNotFound) + return + } + + // cbuf and kbuf are buffers in which the PEM certificates are + // rendered into + var cbuf = new(bytes.Buffer) + var kbuf = new(bytes.Buffer) + + pem.Encode(cbuf, &pem.Block{Type: "CERTIFICATE", Bytes: client.Cert}) + pem.Encode(kbuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: client.PrivateKey}) + + vars := map[string]string{ + "Cert": cbuf.String(), + "Key": kbuf.String(), + } + + t, err := views.GetTemplate("config.ovpn") + if err != nil { + log.Printf("Error loading certificate template: %s", err) + v.RenderError(w, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/x-openvpn-profile") + w.Header().Set("Content-Disposition", "attachment; filename=\"config.ovpn\"") + w.WriteHeader(http.StatusOK) + t.Execute(w, vars) + return + } +} + +func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateKey, error) { + cf, err := ioutil.ReadFile(certFile) + if err != nil { + return nil, nil, err + } + + kf, err := ioutil.ReadFile(keyFile) + if err != nil { + return nil, nil, err + } + cpb, _ := pem.Decode(cf) + kpb, _ := pem.Decode(kf) + crt, err := x509.ParseCertificate(cpb.Bytes) + + if err != nil { + return nil, nil, err + } + key, err := x509.ParsePKCS1PrivateKey(kpb.Bytes) + if err != nil { + return nil, nil, err + } + return crt, key, nil +} + +// CreateCertificate creates a CA-signed certificate +func CreateCertificate(commonName string, key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) { + subj := caCert.Subject + // .. except for the common name + subj.CommonName = commonName + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + log.Fatalf("Obscure error in cert serial number generation: %s", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: subj, + + NotBefore: time.Now().Add(-5 * time.Minute), // account for clock shift + NotAfter: time.Now().Add(24 * time.Hour * 356 * 5), // 5 years ought to be enough! + + SignatureAlgorithm: x509.SHA256WithRSA, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + } + + return x509.CreateCertificate(rand.Reader, &template, caCert, publicKey(key), caKey) +} + +func publicKey(priv interface{}) interface{} { + switch k := priv.(type) { + case *rsa.PrivateKey: + return &k.PublicKey + case *ecdsa.PrivateKey: + return &k.PublicKey + default: + return nil + } +} 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/gencert.go b/handlers/gencert.go deleted file mode 100644 index ac033a0..0000000 --- a/handlers/gencert.go +++ /dev/null @@ -1,115 +0,0 @@ -package handlers - -import ( - "crypto/ecdsa" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "encoding/pem" - "fmt" - "io/ioutil" - "log" - "math/big" - "net/http" - "time" - - "git.klink.asia/paul/certman/views" - - "github.com/jinzhu/gorm" -) - -func ListCertHandler(db *gorm.DB) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - v := views.New(req) - v.Render(w, "cert_list") - } -} - -func GenCertHandler(db *gorm.DB) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - v := views.New(req) - - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - log.Fatalf("Could not generate keypair: %s", err) - } - - caCert, caKey, err := loadX509KeyPair("ca.crt", "ca.key") - if err != nil { - v.Render(w, "500") - log.Fatalf("error loading ca keyfiles: %s", err) - } - - derBytes, err := CreateCertificate(key, caCert, caKey) - pem.Encode(w, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) - - pkBytes := x509.MarshalPKCS1PrivateKey(key) - pem.Encode(w, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: pkBytes}) - return - } -} - -func loadX509KeyPair(certFile, keyFile string) (*x509.Certificate, *rsa.PrivateKey, error) { - cf, err := ioutil.ReadFile(certFile) - if err != nil { - return nil, nil, err - } - - kf, err := ioutil.ReadFile(keyFile) - if err != nil { - return nil, nil, err - } - cpb, cr := pem.Decode(cf) - fmt.Println(string(cr)) - kpb, kr := pem.Decode(kf) - fmt.Println(string(kr)) - crt, err := x509.ParseCertificate(cpb.Bytes) - - if err != nil { - return nil, nil, err - } - key, err := x509.ParsePKCS1PrivateKey(kpb.Bytes) - if err != nil { - return nil, nil, err - } - return crt, key, nil -} - -// CreateCertificate creates a CA-signed certificate -func CreateCertificate(key interface{}, caCert *x509.Certificate, caKey interface{}) ([]byte, error) { - subj := caCert.Subject - // .. except for the common name - subj.CommonName = "clientName" - - serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) - serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) - if err != nil { - log.Fatalf("Obscure error in cert serial number generation: %s", err) - } - - template := x509.Certificate{ - SerialNumber: serialNumber, - Subject: subj, - - NotBefore: time.Now(), - NotAfter: time.Now().Add(24 * time.Hour * 356 * 5), - - SignatureAlgorithm: x509.SHA256WithRSA, - //KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageDataEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, - BasicConstraintsValid: true, - } - - return x509.CreateCertificate(rand.Reader, &template, caCert, publicKey(key), caKey) -} - -func publicKey(priv interface{}) interface{} { - switch k := priv.(type) { - case *rsa.PrivateKey: - return &k.PublicKey - case *ecdsa.PrivateKey: - return &k.PublicKey - default: - return nil - } -} diff --git a/handlers/handlers.go b/handlers/handlers.go index f02ea5a..d131713 100644 --- a/handlers/handlers.go +++ b/handlers/handlers.go @@ -15,3 +15,8 @@ func ErrorHandler(w http.ResponseWriter, req *http.Request) { view := views.New(req) view.RenderError(w, http.StatusInternalServerError) } + +func CSRFErrorHandler(w http.ResponseWriter, req *http.Request) { + view := views.New(req) + view.RenderError(w, http.StatusForbidden) +} diff --git a/handlers/login.go b/handlers/login.go deleted file mode 100644 index 25ebc88..0000000 --- a/handlers/login.go +++ /dev/null @@ -1,33 +0,0 @@ -package handlers - -import ( - "net/http" - - "git.klink.asia/paul/certman/models" - "github.com/jinzhu/gorm" -) - -func LoginHandler(db *gorm.DB) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - // Get parameters - username := req.Form.Get("username") - password := req.Form.Get("password") - - user := models.User{} - - err := db.Where(&models.User{Username: username}).Find(&user).Error - if err != nil { - // could not find user - http.Redirect(w, req, "/login", http.StatusFound) - } - - if err := user.CheckPassword(password); err != nil { - // wrong password - http.Redirect(w, req, "/login", http.StatusFound) - } - - // user is logged in - // set cookie - http.Redirect(w, req, "/certs", http.StatusFound) - } -} 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 2d7fe42..eb74fc6 100644 --- a/main.go +++ b/main.go @@ -1,36 +1,53 @@ package main import ( + "errors" "log" "net/http" + "os" + "time" - "github.com/jinzhu/gorm" + "git.klink.asia/paul/certman/services" - "git.klink.asia/paul/certman/models" "git.klink.asia/paul/certman/router" "git.klink.asia/paul/certman/views" - - // import sqlite3 driver once - _ "github.com/mattn/go-sqlite3" ) func main() { - - // Connect to the database - db, err := gorm.Open("sqlite3", "db.sqlite3") - if err != nil { - log.Fatalf("Could not open database: %s", err.Error()) + log.Println("Initializing certman") + if err := checkCAFilesExist(); err != nil { + log.Fatalf("Could not read CA files: %s", err) } - defer db.Close() - // Migrate - db.AutoMigrate(models.User{}, models.ClientConf{}) + c := services.Config{ + CollectionPath: "./clients.json", + Sessions: &services.SessionsConfig{ + SessionName: "_session", + CookieKey: os.Getenv("APP_KEY"), + HttpOnly: true, + Lifetime: 24 * time.Hour, + }, + } + + log.Println(".. services") + serviceProvider := services.NewProvider(&c) // load and parse template files + log.Println(".. templates") views.LoadTemplates() - mux := router.HandleRoutes(db) + mux := router.HandleRoutes(serviceProvider) - err = http.ListenAndServe(":8000", mux) + log.Println(".. server") + err := http.ListenAndServe(os.Getenv("APP_LISTEN"), mux) log.Fatalf(err.Error()) } + +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 d8178c2..1f18f30 100644 --- a/middleware/requirelogin.go +++ b/middleware/requirelogin.go @@ -1,25 +1,22 @@ package middleware import ( - "log" "net/http" - "runtime/debug" - "git.klink.asia/paul/certman/handlers" + "git.klink.asia/paul/certman/services" ) -func RequireLogin(next http.Handler) http.Handler { - fn := func(w http.ResponseWriter, r *http.Request) { - defer func() { - if rvr := recover(); rvr != nil { - log.Println(rvr) - log.Println(string(debug.Stack())) - handlers.ErrorHandler(w, r) +// RequireLogin is a middleware that checks for a username in the active +// session, and redirects to `/login` if no username was found. +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.GetUsername(req); username == "" { + http.Redirect(w, req, "/login", http.StatusFound) } - }() - next.ServeHTTP(w, r) + next.ServeHTTP(w, req) + } + return http.HandlerFunc(fn) } - - return http.HandlerFunc(fn) } diff --git a/models/model.go b/models/model.go new file mode 100644 index 0000000..01095ce --- /dev/null +++ b/models/model.go @@ -0,0 +1,31 @@ +package models + +import ( + "errors" + "time" +) + +var ( + // ErrNotImplemented gets thrown if some action was not attempted, + // because it is not implemented in the code yet. + ErrNotImplemented = errors.New("Not implemented") +) + +// Client represent the OpenVPN client configuration +type Client struct { + ID uint + CreatedAt time.Time + Name string + User string + Cert []byte + PrivateKey []byte +} + +type ClientProvider interface { + CountClients() (uint, error) + CreateClient(*Client) (*Client, error) + ListClients(count, offset int) ([]*Client, error) + ListClientsForUser(user string, count, offset int) ([]*Client, error) + GetClientByID(id uint) (*Client, error) + DeleteClient(id uint) error +} diff --git a/models/models.go b/models/models.go deleted file mode 100644 index 49a93cc..0000000 --- a/models/models.go +++ /dev/null @@ -1,42 +0,0 @@ -package models - -import ( - "errors" - - "github.com/jinzhu/gorm" -) - -var ( - // ErrNotImplemented gets thrown if some action was not attempted, - // because it is not implemented in the code yet. - ErrNotImplemented = errors.New("Not implemented") -) - -// User represents a User of the system which is able to log in -type User struct { - gorm.Model - Username string - HashedPassword []byte - IsAdmin bool -} - -// SetPassword sets the password of an user struct, but does not save it yet -func (u *User) SetPassword(password string) error { - return ErrNotImplemented -} - -// CheckPassword compares a supplied plain text password with the internally -// stored password hash, returns error=nil on success. -func (u *User) CheckPassword(password string) error { - return ErrNotImplemented -} - -// ClientConf represent the OpenVPN client configuration -type ClientConf struct { - gorm.Model - Name string - User User - Cert []byte - PublicKey []byte - PrivateKey []byte -} diff --git a/router/router.go b/router/router.go index 00a4c78..36dbfc2 100644 --- a/router/router.go +++ b/router/router.go @@ -5,13 +5,15 @@ import ( "os" "strings" + "git.klink.asia/paul/certman/services" + "golang.org/x/oauth2" + "git.klink.asia/paul/certman/assets" "git.klink.asia/paul/certman/handlers" "git.klink.asia/paul/certman/views" "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/gorilla/csrf" - "github.com/jinzhu/gorm" mw "git.klink.asia/paul/certman/middleware" ) @@ -24,16 +26,31 @@ var ( cookieKey = []byte("osx70sMD8HZG2ouUl8uKI4wcMugiJ2WH") ) -func HandleRoutes(db *gorm.DB) http.Handler { +func HandleRoutes(provider *services.Provider) http.Handler { mux := chi.NewMux() - // mux.Use(middleware.RequestID) - mux.Use(middleware.Logger) - mux.Use(middleware.RealIP) - mux.Use(middleware.RedirectSlashes) - mux.Use(mw.Recoverer) + //mux.Use(middleware.RequestID) + mux.Use(middleware.Logger) // log requests + mux.Use(middleware.RealIP) // use proxy headers + mux.Use(middleware.RedirectSlashes) // redirect trailing slashes + 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). fileServer(mux, "/static", assets.Assets) mux.Route("/", func(r chi.Router) { @@ -43,25 +60,26 @@ func HandleRoutes(db *gorm.DB) http.Handler { csrf.Secure(false), csrf.CookieName(csrfCookieName), csrf.FieldName(csrfFieldName), + csrf.ErrorHandler(http.HandlerFunc(handlers.CSRFErrorHandler)), )) } - r.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) { - view := views.New(req) - view.Render(w, "debug") + r.HandleFunc("/", http.RedirectHandler("certs", http.StatusFound).ServeHTTP) + + r.Route("/login", func(r chi.Router) { + r.Get("/", handlers.GetLoginHandler(provider, oauth2Config)) + r.Get("/oauth2/redirect", handlers.OAuth2Endpoint(provider, oauth2Config)) }) - r.Get("/login", func(w http.ResponseWriter, req *http.Request) { - view := views.New(req) - view.Render(w, "login") + r.Route("/certs", func(r chi.Router) { + r.Use(mw.RequireLogin(provider.Sessions)) + r.Get("/", handlers.ListClientsHandler(provider)) + r.Post("/new", handlers.CreateCertHandler(provider)) + r.HandleFunc("/download/{name}", handlers.DownloadCertHandler(provider)) + r.Post("/delete/{name}", handlers.DeleteCertHandler(provider)) }) - r.Get("/certs", handlers.ListCertHandler(db)) - r.HandleFunc("/certs/new", handlers.GenCertHandler(db)) - - 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 @@ -70,6 +88,14 @@ func HandleRoutes(db *gorm.DB) http.Handler { return mux } +// v is a helper function for quickly displaying a view +func v(template string) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + view := views.New(req) + view.Render(w, template) + } +} + // fileServer sets up a http.FileServer handler to serve // static files from a http.FileSystem. func fileServer(r chi.Router, path string, root http.FileSystem) { 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/provider.go b/services/provider.go new file mode 100644 index 0000000..f52fcd9 --- /dev/null +++ b/services/provider.go @@ -0,0 +1,21 @@ +package services + +type Config struct { + CollectionPath string + Sessions *SessionsConfig +} + +type Provider struct { + ClientCollection *ClientCollection + Sessions *Sessions +} + +// NewProvider returns the ServiceProvider +func NewProvider(conf *Config) *Provider { + var provider = &Provider{} + + provider.ClientCollection = NewClientCollection(conf.CollectionPath) + provider.Sessions = NewSessions(conf.Sessions) + + return provider +} diff --git a/services/sessions.go b/services/sessions.go new file mode 100644 index 0000000..83da679 --- /dev/null +++ b/services/sessions.go @@ -0,0 +1,123 @@ +package services + +import ( + "encoding/gob" + "fmt" + "html/template" + "log" + "net/http" + "time" + + "github.com/alexedwards/scs" +) + +var ( + // FlashesKey is the key used for the flashes in the cookie + FlashesKey = "_flashes" + // UserEmailKey is the key used to reference usernames + UserEmailKey = "_user_email" +) + +func init() { + // Register the Flash message type, so gob can serialize it + gob.Register(Flash{}) +} + +type SessionsConfig struct { + SessionName string + CookieKey string + HttpOnly bool + Secure bool + Lifetime time.Duration +} + +// Sessions is a wrapped scs.Store in order to implement custom logic +type Sessions struct { + *scs.Manager +} + +// NewSessions populates the default sessions Store +func NewSessions(conf *SessionsConfig) *Sessions { + store := scs.NewCookieManager( + conf.CookieKey, + ) + store.Name(conf.SessionName) + store.HttpOnly(true) + store.Lifetime(conf.Lifetime) + store.Secure(conf.Secure) + + return &Sessions{store} +} + +func (store *Sessions) GetUsername(req *http.Request) string { + if store == nil { + // if store was not initialized, all requests fail + log.Println("Nil pointer when checking session for username") + return "" + } + + sess := store.Load(req) + + email, err := sess.GetString(UserEmailKey) + if err != nil { + // Username found + return "" + + } + + // User is logged in + return email +} + +func (store *Sessions) SetUsername(w http.ResponseWriter, req *http.Request, username string) { + if store == nil { + // if store was not initialized, do nothing + return + } + + sess := store.Load(req) + + // renew token to avoid session pinning/fixation attack + sess.RenewToken(w) + + sess.PutString(w, UserEmailKey, username) + +} + +type Flash struct { + Message template.HTML + Type string +} + +// Render renders the flash message as a notification box +func (flash Flash) Render() template.HTML { + return template.HTML( + fmt.Sprintf( + "
%s
", + flash.Type, flash.Message, + ), + ) +} + +// Flash add flash message to session data +func (store *Sessions) Flash(w http.ResponseWriter, req *http.Request, flash Flash) error { + var flashes []Flash + + sess := store.Load(req) + + if err := sess.GetObject(FlashesKey, &flashes); err != nil { + return err + } + + flashes = append(flashes, flash) + + return sess.PutObject(w, FlashesKey, flashes) +} + +// Flashes returns a slice of flash messages from session data +func (store *Sessions) Flashes(w http.ResponseWriter, req *http.Request) []Flash { + var flashes []Flash + sess := store.Load(req) + sess.PopObject(w, FlashesKey, &flashes) + return flashes +} diff --git a/settings/settings.go b/settings/settings.go new file mode 100644 index 0000000..d8b00fd --- /dev/null +++ b/settings/settings.go @@ -0,0 +1,10 @@ +package settings + +import "os" + +func Get(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} diff --git a/views/funcs.go b/views/funcs.go index 099785f..2023d79 100644 --- a/views/funcs.go +++ b/views/funcs.go @@ -8,11 +8,13 @@ import ( ) var funcs = template.FuncMap{ - "assetURL": assetURLFn, + "asset": assetURLFn, + "url": relURLFn, "lower": lower, "upper": upper, "date": dateFn, "humanDate": readableDateFn, + "t": translateFn, } func lower(input string) string { @@ -28,6 +30,11 @@ func assetURLFn(input string) string { return fmt.Sprintf("%s%s", url, input) } +func relURLFn(input string) string { + url := "/" //os.Getenv("ASSET_URL") + return fmt.Sprintf("%s%s", url, input) +} + func dateFn(format string, input interface{}) string { var t time.Time switch date := input.(type) { @@ -40,6 +47,10 @@ func dateFn(format string, input interface{}) string { return t.Format(format) } +func translateFn(language string, text string) string { + return text +} + func readableDateFn(t time.Time) string { if time.Now().Before(t) { return "in the future" diff --git a/views/templates.go b/views/templates.go index da64dd4..ec1655d 100644 --- a/views/templates.go +++ b/views/templates.go @@ -18,11 +18,15 @@ var templates map[string]*template.Template func LoadTemplates() { templates = map[string]*template.Template{ "401": newTemplate("layouts/application.gohtml", "errors/401.gohtml"), + "403": newTemplate("layouts/application.gohtml", "errors/403.gohtml"), "404": newTemplate("layouts/application.gohtml", "errors/404.gohtml"), "500": newTemplate("layouts/application.gohtml", "errors/500.gohtml"), - "debug": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/debug.gohtml"), - "login": newTemplate("layouts/application.gohtml", "shared/header.gohtml", "shared/footer.gohtml", "views/login.gohtml"), + "login": newTemplate("layouts/auth.gohtml", "views/login.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 69c0bb2..f5185e6 100644 --- a/views/views.go +++ b/views/views.go @@ -7,12 +7,15 @@ import ( "log" "net/http" + "git.klink.asia/paul/certman/services" + "github.com/gorilla/csrf" ) type View struct { - Vars map[string]interface{} - Request *http.Request + Vars map[string]interface{} + Request *http.Request + SessionStore *services.Sessions } func New(req *http.Request) *View { @@ -25,6 +28,25 @@ func New(req *http.Request) *View { "Path": req.URL.Path, "Env": "develop", }, + "flashes": []services.Flash{}, + "username": "", + }, + } +} + +func NewWithSession(req *http.Request, sessionStore *services.Sessions) *View { + return &View{ + Request: req, + SessionStore: sessionStore, + Vars: map[string]interface{}{ + "CSRF_TOKEN": csrf.Token(req), + "csrfField": csrf.TemplateField(req), + "Meta": map[string]interface{}{ + "Path": req.URL.Path, + "Env": "develop", + }, + "flashes": []services.Flash{}, + "username": sessionStore.GetUsername(req), }, } } @@ -39,6 +61,11 @@ func (view View) Render(w http.ResponseWriter, name string) { return } + if view.SessionStore != nil { + // add flashes to template + view.Vars["flashes"] = view.SessionStore.Flashes(w, view.Request) + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusOK) t.Execute(w, view.Vars) @@ -49,12 +76,12 @@ func (view View) RenderError(w http.ResponseWriter, status int) { var name string switch status { - case http.StatusNotFound: - name = "404" case http.StatusUnauthorized: name = "401" case http.StatusForbidden: name = "403" + case http.StatusNotFound: + name = "404" default: name = "500" }