From d2e0291cc9db9b85d9ed09d3f02c3fcef1d92950 Mon Sep 17 00:00:00 2001
From: Paul Greenberg <greenpau@outlook.com>
Date: Sun, 6 Dec 2020 19:42:59 -0500
Subject: [PATCH] add mfa app testing facilities

Partial Resolution: #13
---
 assets/templates/basic/settings.template | 57 ++++++++++++++++-
 go.mod                                   |  2 +-
 go.sum                                   |  4 +-
 pkg/handlers/settings.go                 | 80 ++++++++++++++++++++++++
 pkg/ui/pages.go                          | 57 ++++++++++++++++-
 5 files changed, 193 insertions(+), 7 deletions(-)

diff --git a/assets/templates/basic/settings.template b/assets/templates/basic/settings.template
index 64e0e67..b634911 100644
--- a/assets/templates/basic/settings.template
+++ b/assets/templates/basic/settings.template
@@ -297,7 +297,12 @@
                 </div>
                 <div class="card-action">
                   <a href="{{ pathjoin $.ActionEndpoint "/settings/mfa/delete/" .ID }}">Delete</a>
-                  <a href="{{ pathjoin $.ActionEndpoint "/settings/mfa/test/" .ID }}">Test</a>
+                  {{ if eq .Type "totp" }}
+                  <a href="{{ pathjoin $.ActionEndpoint "/settings/mfa/test/app/" .ID }}">Test</a>
+                  {{ end }}
+                  {{ if eq .Type "u2f" }}
+                  <a href="{{ pathjoin $.ActionEndpoint "/settings/mfa/test/u2f/" .ID }}">Test</a>
+                  {{ end }}
                 </div>
               </div>
               {{ end }}
@@ -337,7 +342,7 @@
                     <input id="secret" name="secret" type="hidden" value="{{ .Data.mfa_secret }}" />
                     <input id="type" name="type" type="hidden" value="{{ .Data.mfa_type }}" />
                     <input id="period" name="period" type="hidden" value="{{ .Data.mfa_period }}" />
-                    <input id="period" name="digits" type="hidden" value="{{ .Data.mfa_digits }}" />
+                    <input id="digits" name="digits" type="hidden" value="{{ .Data.mfa_digits }}" />
                   </div>
                   <div class="col s12 m6 l6">
                     <div class="center-align"><img src="{{ pathjoin .ActionEndpoint "/settings/mfa/barcode/" .Data.code_uri_encoded }}.png" alt="QR Code" /></div>
@@ -376,6 +381,54 @@
             </div>
           </div>
           {{ end }}
+          {{ if eq .Data.view "mfa-test-app" }}
+            <form action="{{ pathjoin .ActionEndpoint "/settings/mfa/test/app/" .Data.mfa_token_id }}" method="POST">
+              <div class="row">
+                <h1>Test MFA Authenticator Application</h1>
+                <div class="row">
+                  <div class="col s12 m6 l6">
+                    <p>Please open your MFA authenticator application to view your authentication code and verify your identity</p>
+                    <div class="input-field">
+                      <input id="passcode" name="passcode" type="text" class="validate" pattern="[0-9]{6}"
+                        title="Passcode should contain 6 characters and consists of 0-9 characters."
+                        required />
+                      <label for="passcode">Passcode</label>
+                    </div>
+                    <input id="token_id" name="token_id" type="hidden" value="{{ .Data.mfa_token_id }}" />
+                  </div>
+                </div>
+              </div>
+              <div class="row">
+                <button type="submit" name="submit" class="btn waves-effect waves-light navbtn active navbtn-last app-btn">
+                  <i class="las la-plus-circle left app-btn-icon"></i>
+                  <span class="app-btn-text">Validate</span>
+                </button>
+              </div>
+            </form>
+          {{ end }}
+          {{ if eq .Data.view "mfa-test-app-status" }}
+          <div class="row">
+            <div class="col s12">
+            <h1>Test MFA Authenticator Application</h1>
+            <p>{{.Data.status }}: {{ .Data.status_reason }}</p>
+            {{ if eq .Data.status "SUCCESS" }}
+              <a href="{{ pathjoin .ActionEndpoint "/settings/mfa" }}">
+                <button type="button" class="btn waves-effect waves-light navbtn active">
+                  <i class="las la-undo-alt left app-btn-icon"></i>
+                  <span class="app-btn-text">Go Back</span>
+                </button>
+              </a>
+            {{ else }}
+              <a href="{{ pathjoin .ActionEndpoint "/settings/mfa/test/app/" .Data.mfa_token_id }}">
+                <button type="button" class="btn waves-effect waves-light navbtn active">
+                  <i class="las la-undo-alt left app-btn-icon"></i>
+                  <span class="app-btn-text">Try Again</span>
+                </button>
+              </a>
+            {{ end }}
+            </div>
+          </div>
+          {{ end }}
           {{ if eq .Data.view "mfa-delete-status" }}
           <div class="row">
             <div class="col s12">
diff --git a/go.mod b/go.mod
index 238cbdb..435bb61 100644
--- a/go.mod
+++ b/go.mod
@@ -8,7 +8,7 @@ require (
 	github.com/dgrijalva/jwt-go v3.2.0+incompatible
 	github.com/go-ldap/ldap v3.0.3+incompatible
 	github.com/greenpau/caddy-auth-jwt v1.2.1
-	github.com/greenpau/go-identity v1.0.17
+	github.com/greenpau/go-identity v1.0.18
 	github.com/satori/go.uuid v1.2.0
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	go.uber.org/zap v1.15.0
diff --git a/go.sum b/go.sum
index 866c208..1f31109 100644
--- a/go.sum
+++ b/go.sum
@@ -301,8 +301,8 @@ github.com/gostaticanalysis/analysisutil v0.0.0-20190318220348-4088753ea4d3/go.m
 github.com/gostaticanalysis/analysisutil v0.0.3/go.mod h1:eEOZF4jCKGi+aprrirO9e7WKB3beBRtWgqGunKl6pKE=
 github.com/greenpau/caddy-auth-jwt v1.2.1 h1:m04X6g6gvbMIdTpPbPYZRU4RQaFy5cKQk7ym/j6m/Tg=
 github.com/greenpau/caddy-auth-jwt v1.2.1/go.mod h1:XS1wGcSSSS9H544mis5b640ae/hNL58Nbu/tCU6PVZo=
-github.com/greenpau/go-identity v1.0.17 h1:aGs7T5tI47p9nBFn/gRyT0AMVUYnG2YsTMiu0hHNN5U=
-github.com/greenpau/go-identity v1.0.17/go.mod h1:++pwfuoXgXJQM1PFtiHTH9fMlbgOY42Hs5wSwoQ3JK0=
+github.com/greenpau/go-identity v1.0.18 h1:F0V3gSqPYWGiGIE3121tY05JOrYxYym8aapIrBcIYjY=
+github.com/greenpau/go-identity v1.0.18/go.mod h1:++pwfuoXgXJQM1PFtiHTH9fMlbgOY42Hs5wSwoQ3JK0=
 github.com/greenpau/versioned v1.0.23 h1:ICqCoTG8Xv92BV+bKs52d86pDF/e0zhk3LLELsYMpl4=
 github.com/greenpau/versioned v1.0.23/go.mod h1:rtFCvaWWNbMH4CJnje/xicgmrM63j++rUh5juSu0k/A=
 github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
diff --git a/pkg/handlers/settings.go b/pkg/handlers/settings.go
index d85c6a8..b430b7a 100644
--- a/pkg/handlers/settings.go
+++ b/pkg/handlers/settings.go
@@ -156,6 +156,59 @@ func ServeSettings(w http.ResponseWriter, r *http.Request, opts map[string]inter
 						}
 					}
 				}
+			case "test":
+				if len(viewParts) > 3 {
+					resp.Data["mfa_token_id"] = strings.TrimSpace(viewParts[3])
+					if r.Method == "POST" {
+						resp.Data["status"] = "FAIL"
+						if backend != nil {
+							switch viewParts[2] {
+							case "app":
+								view = "mfa-test-app-status"
+								var passcodeValid bool
+								if tokenID, passcode, err := validateMfaAuthTokenForm(r); err != nil {
+									resp.Data["status_reason"] = fmt.Sprintf("Bad Request: %s", err)
+								} else {
+									args := make(map[string]interface{})
+									args["username"] = claims.Subject
+									args["email"] = claims.Email
+									mfaTokens, err := backend.GetMfaTokens(args)
+									if err != nil {
+										resp.Data["status_reason"] = fmt.Sprintf("%s", err)
+									} else {
+										for _, mfaToken := range mfaTokens {
+											if mfaToken.ID != tokenID {
+												continue
+											}
+											if err := mfaToken.ValidateCode(passcode); err == nil {
+												passcodeValid = true
+												resp.Data["status"] = "SUCCESS"
+												resp.Data["status_reason"] = fmt.Sprintf("token %s validated successfully", mfaToken.ID)
+												break
+											}
+										}
+										if !passcodeValid {
+											resp.Data["status_reason"] = fmt.Sprintf("invalid passcode")
+										}
+									}
+								}
+							case "u2f":
+								view = "mfa-test-u2f-status"
+								resp.Data["status_reason"] = "Not implemented"
+							}
+						} else {
+							resp.Data["status_reason"] = "Authentication backend not found"
+						}
+					} else {
+						// Get token ID from path
+						switch viewParts[2] {
+						case "app":
+							view = "mfa-test-app"
+						case "u2f":
+							view = "mfa-test-u2f"
+						}
+					}
+				}
 			}
 		} else {
 			// Entry Page
@@ -406,6 +459,33 @@ func validateKeyInputForm(r *http.Request) (map[string]string, error) {
 	return resp, nil
 }
 
+func validateMfaAuthTokenForm(r *http.Request) (string, string, error) {
+	if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
+		return "", "", fmt.Errorf("Unsupported content type")
+	}
+	if err := r.ParseForm(); err != nil {
+		return "", "", fmt.Errorf("Failed parsing submitted form")
+	}
+
+	passcode := r.PostFormValue("passcode")
+	passcode = strings.TrimSpace(passcode)
+	if passcode == "" {
+		return "", "", fmt.Errorf("Required form passcode field is empty")
+	}
+
+	if len(passcode) < 4 || len(passcode) > 8 {
+		return "", "", fmt.Errorf("MFA passcode is not 4-8 characters long")
+	}
+
+	tokenID := r.PostFormValue("token_id")
+	tokenID = strings.TrimSpace(tokenID)
+	if tokenID == "" {
+		return "", "", fmt.Errorf("Required form token_id field is empty")
+	}
+
+	return tokenID, passcode, nil
+}
+
 func validateAddMfaTokenForm(r *http.Request) (map[string]string, error) {
 	resp := make(map[string]string)
 	if r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
diff --git a/pkg/ui/pages.go b/pkg/ui/pages.go
index d618df4..7c159df 100644
--- a/pkg/ui/pages.go
+++ b/pkg/ui/pages.go
@@ -792,7 +792,12 @@ var PageTemplates = map[string]string{
                 </div>
                 <div class="card-action">
                   <a href="{{ pathjoin $.ActionEndpoint "/settings/mfa/delete/" .ID }}">Delete</a>
-                  <a href="{{ pathjoin $.ActionEndpoint "/settings/mfa/test/" .ID }}">Test</a>
+                  {{ if eq .Type "totp" }}
+                  <a href="{{ pathjoin $.ActionEndpoint "/settings/mfa/test/app/" .ID }}">Test</a>
+                  {{ end }}
+                  {{ if eq .Type "u2f" }}
+                  <a href="{{ pathjoin $.ActionEndpoint "/settings/mfa/test/u2f/" .ID }}">Test</a>
+                  {{ end }}
                 </div>
               </div>
               {{ end }}
@@ -832,7 +837,7 @@ var PageTemplates = map[string]string{
                     <input id="secret" name="secret" type="hidden" value="{{ .Data.mfa_secret }}" />
                     <input id="type" name="type" type="hidden" value="{{ .Data.mfa_type }}" />
                     <input id="period" name="period" type="hidden" value="{{ .Data.mfa_period }}" />
-                    <input id="period" name="digits" type="hidden" value="{{ .Data.mfa_digits }}" />
+                    <input id="digits" name="digits" type="hidden" value="{{ .Data.mfa_digits }}" />
                   </div>
                   <div class="col s12 m6 l6">
                     <div class="center-align"><img src="{{ pathjoin .ActionEndpoint "/settings/mfa/barcode/" .Data.code_uri_encoded }}.png" alt="QR Code" /></div>
@@ -871,6 +876,54 @@ var PageTemplates = map[string]string{
             </div>
           </div>
           {{ end }}
+          {{ if eq .Data.view "mfa-test-app" }}
+            <form action="{{ pathjoin .ActionEndpoint "/settings/mfa/test/app/" .Data.mfa_token_id }}" method="POST">
+              <div class="row">
+                <h1>Test MFA Authenticator Application</h1>
+                <div class="row">
+                  <div class="col s12 m6 l6">
+                    <p>Please open your MFA authenticator application to view your authentication code and verify your identity</p>
+                    <div class="input-field">
+                      <input id="passcode" name="passcode" type="text" class="validate" pattern="[0-9]{6}"
+                        title="Passcode should contain 6 characters and consists of 0-9 characters."
+                        required />
+                      <label for="passcode">Passcode</label>
+                    </div>
+                    <input id="token_id" name="token_id" type="hidden" value="{{ .Data.mfa_token_id }}" />
+                  </div>
+                </div>
+              </div>
+              <div class="row">
+                <button type="submit" name="submit" class="btn waves-effect waves-light navbtn active navbtn-last app-btn">
+                  <i class="las la-plus-circle left app-btn-icon"></i>
+                  <span class="app-btn-text">Validate</span>
+                </button>
+              </div>
+            </form>
+          {{ end }}
+          {{ if eq .Data.view "mfa-test-app-status" }}
+          <div class="row">
+            <div class="col s12">
+            <h1>Test MFA Authenticator Application</h1>
+            <p>{{.Data.status }}: {{ .Data.status_reason }}</p>
+            {{ if eq .Data.status "SUCCESS" }}
+              <a href="{{ pathjoin .ActionEndpoint "/settings/mfa" }}">
+                <button type="button" class="btn waves-effect waves-light navbtn active">
+                  <i class="las la-undo-alt left app-btn-icon"></i>
+                  <span class="app-btn-text">Go Back</span>
+                </button>
+              </a>
+            {{ else }}
+              <a href="{{ pathjoin .ActionEndpoint "/settings/mfa/test/app/" .Data.mfa_token_id }}">
+                <button type="button" class="btn waves-effect waves-light navbtn active">
+                  <i class="las la-undo-alt left app-btn-icon"></i>
+                  <span class="app-btn-text">Try Again</span>
+                </button>
+              </a>
+            {{ end }}
+            </div>
+          </div>
+          {{ end }}
           {{ if eq .Data.view "mfa-delete-status" }}
           <div class="row">
             <div class="col s12">