DodoApp: Implement internal auth

Follow up change will make internal auth optional, and let user
configure dodo-app to use environment wise auth service.

Change-Id: Ie308b30becd4390f3d9a07caf6f894b8bd4ebf3a
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index 89b7ad2..75d12b9 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -1,9 +1,11 @@
 package welcome
 
 import (
+	"context"
 	"encoding/json"
 	"errors"
 	"fmt"
+	"golang.org/x/crypto/bcrypt"
 	"io"
 	"io/fs"
 	"net/http"
@@ -14,11 +16,16 @@
 	"github.com/giolekva/pcloud/core/installer/soft"
 
 	"github.com/gorilla/mux"
+	"github.com/gorilla/securecookie"
 )
 
 const (
 	ConfigRepoName = "config"
 	namespacesFile = "/namespaces.json"
+	loginPath      = "/login"
+	logoutPath     = "/logout"
+	sessionCookie  = "dodo-app-session"
+	userCtx        = "user"
 )
 
 type DodoAppServer struct {
@@ -36,6 +43,7 @@
 	jc               installer.JobCreator
 	workers          map[string]map[string]struct{}
 	appNs            map[string]string
+	sc               *securecookie.SecureCookie
 }
 
 // TODO(gio): Initialize appNs on startup
@@ -52,6 +60,10 @@
 	jc installer.JobCreator,
 	env installer.EnvConfig,
 ) (*DodoAppServer, error) {
+	sc := securecookie.New(
+		securecookie.GenerateRandomKey(64),
+		securecookie.GenerateRandomKey(32),
+	)
 	s := &DodoAppServer{
 		&sync.Mutex{},
 		st,
@@ -67,6 +79,7 @@
 		jc,
 		map[string]map[string]struct{}{},
 		map[string]string{},
+		sc,
 	}
 	config, err := client.GetRepo(ConfigRepoName)
 	if err != nil {
@@ -88,23 +101,132 @@
 	e := make(chan error)
 	go func() {
 		r := mux.NewRouter()
-		r.HandleFunc("/status/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
-		r.HandleFunc("/status", s.handleStatus).Methods(http.MethodGet)
+		r.Use(s.mwAuth)
+		r.HandleFunc(logoutPath, s.handleLogout).Methods(http.MethodGet)
+		r.HandleFunc("/{app-name}"+loginPath, s.handleLoginForm).Methods(http.MethodGet)
+		r.HandleFunc("/{app-name}"+loginPath, s.handleLogin).Methods(http.MethodPost)
+		r.HandleFunc("/{app-name}", s.handleAppStatus).Methods(http.MethodGet)
+		r.HandleFunc("/", s.handleStatus).Methods(http.MethodGet)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
 	}()
 	go func() {
 		r := mux.NewRouter()
-		r.HandleFunc("/update", s.handleUpdate)
-		r.HandleFunc("/api/apps/{app-name}/workers", s.handleRegisterWorker).Methods(http.MethodPost)
-		r.HandleFunc("/api/apps", s.handleCreateApp).Methods(http.MethodPost)
-		r.HandleFunc("/api/add-admin-key", s.handleAddAdminKey).Methods(http.MethodPost)
+		r.HandleFunc("/update", s.handleApiUpdate)
+		r.HandleFunc("/api/apps/{app-name}/workers", s.handleApiRegisterWorker).Methods(http.MethodPost)
+		r.HandleFunc("/api/apps", s.handleApiCreateApp).Methods(http.MethodPost)
+		r.HandleFunc("/api/add-admin-key", s.handleApiAddAdminKey).Methods(http.MethodPost)
 		e <- http.ListenAndServe(fmt.Sprintf(":%d", s.apiPort), r)
 	}()
 	return <-e
 }
 
+func (s *DodoAppServer) mwAuth(next http.Handler) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if strings.HasSuffix(r.URL.Path, loginPath) || strings.HasPrefix(r.URL.Path, logoutPath) {
+			next.ServeHTTP(w, r)
+			return
+		}
+		cookie, err := r.Cookie(sessionCookie)
+		if err != nil {
+			vars := mux.Vars(r)
+			appName, ok := vars["app-name"]
+			if !ok || appName == "" {
+				http.Error(w, "missing app-name", http.StatusBadRequest)
+				return
+			}
+			http.Redirect(w, r, fmt.Sprintf("/%s%s", appName, loginPath), http.StatusSeeOther)
+			return
+		}
+		var user string
+		if err := s.sc.Decode(sessionCookie, cookie.Value, &user); err != nil {
+			http.Error(w, "unauthorized", http.StatusUnauthorized)
+			return
+		}
+		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userCtx, user)))
+	})
+}
+
+func (s *DodoAppServer) handleLogout(w http.ResponseWriter, r *http.Request) {
+	http.SetCookie(w, &http.Cookie{
+		Name:     sessionCookie,
+		Value:    "",
+		Path:     "/",
+		HttpOnly: true,
+		Secure:   true,
+	})
+	http.Redirect(w, r, "/", http.StatusSeeOther)
+}
+
+func (s *DodoAppServer) handleLoginForm(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+	appName, ok := vars["app-name"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	fmt.Fprint(w, `
+<!DOCTYPE html>
+<html lang='en'>
+	<head>
+		<title>dodo: app - login</title>
+		<meta charset='utf-8'>
+	</head>
+	<body>
+        <form action="" method="POST">
+          <input type="password" placeholder="Password" name="password" required />
+          <button type="submit">Login</button>
+        </form>
+	</body>
+</html>
+`)
+}
+
+func (s *DodoAppServer) handleLogin(w http.ResponseWriter, r *http.Request) {
+	vars := mux.Vars(r)
+	appName, ok := vars["app-name"]
+	if !ok || appName == "" {
+		http.Error(w, "missing app-name", http.StatusBadRequest)
+		return
+	}
+	password := r.FormValue("password")
+	if password == "" {
+		http.Error(w, "missing password", http.StatusBadRequest)
+		return
+	}
+	user, err := s.st.GetAppOwner(appName)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	hashed, err := s.st.GetUserPassword(user)
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := bcrypt.CompareHashAndPassword(hashed, []byte(password)); err != nil {
+		http.Redirect(w, r, r.URL.Path, http.StatusSeeOther)
+		return
+	}
+	if encoded, err := s.sc.Encode(sessionCookie, user); err == nil {
+		cookie := &http.Cookie{
+			Name:     sessionCookie,
+			Value:    encoded,
+			Path:     "/",
+			Secure:   true,
+			HttpOnly: true,
+		}
+		http.SetCookie(w, cookie)
+	}
+	http.Redirect(w, r, fmt.Sprintf("/%s", appName), http.StatusSeeOther)
+}
+
 func (s *DodoAppServer) handleStatus(w http.ResponseWriter, r *http.Request) {
-	apps, err := s.st.GetApps()
+	user := r.Context().Value(userCtx)
+	if user == nil {
+		http.Error(w, "unauthorized", http.StatusUnauthorized)
+		return
+	}
+	apps, err := s.st.GetUserApps(user.(string))
 	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -131,7 +253,7 @@
 	}
 }
 
-type updateReq struct {
+type apiUpdateReq struct {
 	Ref        string `json:"ref"`
 	Repository struct {
 		Name string `json:"name"`
@@ -139,9 +261,9 @@
 	After string `json:"after"`
 }
 
-func (s *DodoAppServer) handleUpdate(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleApiUpdate(w http.ResponseWriter, r *http.Request) {
 	fmt.Println("update")
-	var req updateReq
+	var req apiUpdateReq
 	var contents strings.Builder
 	io.Copy(&contents, r.Body)
 	c := contents.String()
@@ -173,18 +295,18 @@
 	}()
 }
 
-type registerWorkerReq struct {
+type apiRegisterWorkerReq struct {
 	Address string `json:"address"`
 }
 
-func (s *DodoAppServer) handleRegisterWorker(w http.ResponseWriter, r *http.Request) {
+func (s *DodoAppServer) handleApiRegisterWorker(w http.ResponseWriter, r *http.Request) {
 	vars := mux.Vars(r)
 	appName, ok := vars["app-name"]
 	if !ok || appName == "" {
 		http.Error(w, "missing app-name", http.StatusBadRequest)
 		return
 	}
-	var req registerWorkerReq
+	var req apiRegisterWorkerReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
@@ -195,16 +317,17 @@
 	s.workers[appName][req.Address] = struct{}{}
 }
 
-type createAppReq struct {
+type apiCreateAppReq struct {
 	AdminPublicKey string `json:"adminPublicKey"`
 }
 
-type createAppResp struct {
-	AppName string `json:"appName"`
+type apiCreateAppResp struct {
+	AppName  string `json:"appName"`
+	Password string `json:"password"`
 }
 
-func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
-	var req createAppReq
+func (s *DodoAppServer) handleApiCreateApp(w http.ResponseWriter, r *http.Request) {
+	var req apiCreateAppReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
@@ -215,62 +338,96 @@
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	if err := s.CreateApp(appName, req.AdminPublicKey); err != nil {
+	password, err := s.CreateApp(appName, req.AdminPublicKey)
+	if err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
-	resp := createAppResp{appName}
+	resp := apiCreateAppResp{
+		AppName:  appName,
+		Password: password,
+	}
 	if err := json.NewEncoder(w).Encode(resp); err != nil {
 		http.Error(w, err.Error(), http.StatusInternalServerError)
 		return
 	}
 }
 
-func (s *DodoAppServer) CreateApp(appName, adminPublicKey string) error {
+func (s *DodoAppServer) CreateApp(appName, adminPublicKey string) (string, error) {
 	s.l.Lock()
 	defer s.l.Unlock()
 	fmt.Printf("Creating app: %s\n", appName)
 	if ok, err := s.client.RepoExists(appName); err != nil {
-		return err
+		return "", err
 	} else if ok {
-		return nil
+		return "", nil
 	}
-	if err := s.st.CreateApp(appName); err != nil {
-		return err
+	user, err := s.client.FindUser(adminPublicKey)
+	if err != nil {
+		return "", err
+	}
+	if user != "" {
+		if err := s.client.AddPublicKey(user, adminPublicKey); err != nil {
+			return "", err
+		}
+	} else {
+		user = appName
+		if err := s.client.AddUser(user, adminPublicKey); err != nil {
+			return "", err
+		}
+	}
+	password := generatePassword()
+	// TODO(gio): take admin password for initial application as input
+	if appName == "app" {
+		password = "app"
+	}
+	hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return "", err
+	}
+	if err := s.st.CreateUser(user, hashed); err != nil {
+		if !errors.Is(err, ErrorAlreadyExists) {
+			return "", err
+		} else {
+			password = ""
+		}
+	}
+	if err := s.st.CreateApp(appName, user); err != nil {
+		return "", err
 	}
 	if err := s.client.AddRepository(appName); err != nil {
-		return err
+		return "", err
 	}
 	appRepo, err := s.client.GetRepo(appName)
 	if err != nil {
-		return err
+		return "", err
 	}
 	if err := InitRepo(appRepo); err != nil {
-		return err
+		return "", err
 	}
 	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
 	app, err := installer.FindEnvApp(apps, "dodo-app-instance")
 	if err != nil {
-		return err
+		return "", err
 	}
 	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
 	suffix, err := suffixGen.Generate()
 	if err != nil {
-		return err
+		return "", err
 	}
 	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
 	s.appNs[appName] = namespace
 	if err := s.updateDodoApp(appName, namespace); err != nil {
-		return err
+		return "", err
 	}
 	repo, err := s.client.GetRepo(ConfigRepoName)
 	if err != nil {
-		return err
+		return "", err
 	}
 	hf := installer.NewGitHelmFetcher()
 	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/")
 	if err != nil {
-		return err
+		return "", err
 	}
 	if err := repo.Do(func(fs soft.RepoFS) (string, error) {
 		w, err := fs.Writer(namespacesFile)
@@ -299,60 +456,49 @@
 		}
 		return fmt.Sprintf("Installed app: %s", appName), nil
 	}); err != nil {
-		return err
+		return "", err
 	}
 	cfg, err := m.FindInstance(appName)
 	if err != nil {
-		return err
+		return "", err
 	}
 	fluxKeys, ok := cfg.Input["fluxKeys"]
 	if !ok {
-		return fmt.Errorf("Fluxcd keys not found")
+		return "", fmt.Errorf("Fluxcd keys not found")
 	}
 	fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
 	if !ok {
-		return fmt.Errorf("Fluxcd keys not found")
+		return "", fmt.Errorf("Fluxcd keys not found")
 	}
 	if ok, err := s.client.UserExists("fluxcd"); err != nil {
-		return err
+		return "", err
 	} else if ok {
 		if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
-			return err
+			return "", err
 		}
 	} else {
 		if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
-			return err
+			return "", err
 		}
 	}
 	if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
-		return err
+		return "", err
 	}
 	if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
-		return err
+		return "", err
 	}
-	if user, err := s.client.FindUser(adminPublicKey); err != nil {
-		return err
-	} else if user != "" {
-		if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
-			return err
-		}
-	} else {
-		if err := s.client.AddUser(appName, adminPublicKey); err != nil {
-			return err
-		}
-		if err := s.client.AddReadWriteCollaborator(appName, appName); err != nil {
-			return err
-		}
+	if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
+		return "", err
 	}
-	return nil
+	return password, nil
 }
 
-type addAdminKeyReq struct {
+type apiAddAdminKeyReq struct {
 	Public string `json:"public"`
 }
 
-func (s *DodoAppServer) handleAddAdminKey(w http.ResponseWriter, r *http.Request) {
-	var req addAdminKeyReq
+func (s *DodoAppServer) handleApiAddAdminKey(w http.ResponseWriter, r *http.Request) {
+	var req apiAddAdminKeyReq
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
 		http.Error(w, err.Error(), http.StatusBadRequest)
 		return
@@ -470,3 +616,7 @@
 		return "go web app template", nil
 	})
 }
+
+func generatePassword() string {
+	return "foo"
+}