From 084257d34d9dd71fdb31b3f1fc18af811ed98ef6 Mon Sep 17 00:00:00 2001 From: Jeromy Streets Date: Tue, 22 May 2018 17:11:30 -0700 Subject: [PATCH] Rough draft of feature. --- serve.go | 515 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 491 insertions(+), 24 deletions(-) diff --git a/serve.go b/serve.go index dc74f4d..9117000 100644 --- a/serve.go +++ b/serve.go @@ -1,15 +1,92 @@ package main import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" "fmt" + "io" + "io/ioutil" "log" "net/http" "os" "strings" + + "golang.org/x/crypto/scrypt" + + "github.com/howeyc/gopass" ) var ( - version = "Version 1.1" + version = "Version 1.2" + + authHelp = ` +NAME + static-file-server auth + +SYNOPSIS + static-file-server auth [ help | -help | --help ] + static-file-server auth list + static-file-server auth add $username [ $password ] + static-file-server auth update $username [ $password ] + static-file-server auth remove $username + +DESCRIPTION + The Static File Server authentication sub-command is used to securely modify + a credential file for use with basic authentication. + +DEPENDENCIES + None... not even libc! + +ENVIRONMENT VARIABLES + CREDENTIALS + The path to a file that contains valid credentials, or the path to a + file that will have credentials modified. By having this variable set + basic authentication will automatically be used. Credentials must be + added to the file using the 'auth add' command prior to use. If no + credentials are added, then basic authentication will always fail (fails + secure). It is HIGHLY RECOMMENDED to use this with TLS certificates. If + you are not using TLS certificates, don't use credentials that are + important. Username and password are case-sensitive. Variable must be + set to use any authentication sub-commands, with the exception of + requesting help. + +COMMANDS + add $username [ $password ] + Add a new credential to the credential file. If the username is not set + or is the same as an existing username, the command will fail. If the + password for a user needs to be updated, use 'auth update'. If the + password is not supplied, it will be requested during execution. + ARGS: + $username: The case-sensitive username to be added. + $password: The case-sensitive password to be associated with the new + username. + + help + Prints this help documentation. + + list + List all usernames in the credential file. + + remove $username + Remove an existing username from the credential file. If the username is + not set or doesn't match an existing username, the command will fail. + ARGS: + $username: The case-sensitive username to be removed. + + update $username [ $password ] + Update an existing username with a new password in the credential file. + If the username is not set or doesn't match an existing username, the + command will fail. If a new user needs to be added, use 'auth add'. If + the password is not supplied, it will be requested during execution. + ARGS: + $username: The case-sensitive username to be updated with a new + password. + $password: The case-sensitive passwrod to be associated with the + existing username. +` help = ` NAME @@ -19,6 +96,7 @@ SYNOPSIS static-file-server static-file-server [ help | -help | --help ] static-file-server [ version | -version | --version ] + static-file-server auth [ help | -help | --help ] DESCRIPTION The Static File Server is intended to be a tiny, fast and simple solution @@ -31,6 +109,22 @@ DEPENDENCIES None... not even libc! ENVIRONMENT VARIABLES + CREDENTIALS + The path to a file that contains valid credentials, or the path to a + file that will have credentials modified. By having this variable set + basic authentication will automatically be used. Credentials must be + added to the file using the 'auth add' command prior to use. If no + credentials are added, then basic authentication will always fail (fails + secure). It is HIGHLY RECOMMENDED to use this with TLS certificates. If + you are not using TLS certificates, don't use credentials that are + important. Username and password are case-sensitive. + FAST_AUTH + It is recommended to use CREDENTIALS and not FAST_AUTH. FAST_AUTH is + only provided for users that want to share files for a short time only + using an unimportant password. If CREDENTIALS is set, FAST_AUTH will be + ignored. The value of FAST_AUTH is a colon (:) delimited username and + password (for example: FAST_AUTH=user:password). Username and password + are case-sensitive. FOLDER The path to the folder containing the contents to be served over HTTP(s). If not supplied, defaults to '/web' (for Docker reasons). @@ -58,6 +152,19 @@ ENVIRONMENT VARIABLES start with a forward-slash and NOT end with a forward-slash. If not supplied then no prefix is used. +COMMANDS + [No commands supplied] + Serve static files based on passed environment variables. Server will + continue to run until shutdown of the service is requested. + + auth + The CREDENTIALS environment variable must be set to use the + authorization command. The authorization command is used to securely + add, modify and remove user credentials for basic authentication. For + more information, run 'static-file-server auth help'. + + help + Prints this help documentation. USAGE FILE LAYOUT /var/www/sub/my.file @@ -106,37 +213,293 @@ USAGE export SHOW_LISTING=false static-file-server Returns 'NOT FOUND': wget http://my.machine/ + + export CREDENTIALS=credentials.json + export FOLDER=/var/www/sub + export PORT=443 + export TLS_CERT=/etc/server/my.machine.crt + export TLS_KEY=/etc/server/my.machine.key + static-file-server auth add 'user' 'pass' + static-file-server auth add 'john' '12345' + static-file-server + Retrieve with: + wget --user 'user' --password 'pass' --auth-no-challenge \ + https://my.machine/my.file + wget --user 'john' --password '12345' --auth-no-challenge \ + https://my.machine/my.file + + export FAST_AUTH=user:pass + export FOLDER=/var/www/sub + export PORT=443 + export TLS_CERT=/etc/server/my.machine.crt + export TLS_KEY=/etc/server/my.machine.key + static-file-server + Retrieve with: + wget --user 'user' --password 'pass' --auth-no-challenge \ + https://my.machine/my.file ` + + credentials map[string]*credential + + envvar struct { + credentialFile string + fastAuth string + folder string + host string + port string + showListing bool + tlsCert string + tlsKey string + urlPrefix string + } ) +// credential that is stored and used for authentication. +type credential struct { + // Salt (random) for uniquely encrypting each password. + Salt string `json:"salt"` + // Password after it has been encrypted with the salt. + Password string `json:"pass"` +} + +// matches the passed uncrypted password against the assigned encrypted +// password. Returns true if the passwords match. +func (c *credential) matches(password string) (matches bool, err error) { + matches = false + var enc string + if enc, err = c.encrypt(password, c.Salt); nil != err { + return + } + matches = enc == c.Password + return +} + +// update the encrypted credential password with a new, unencrypted password. +func (c *credential) update(password string) (err error) { + // Create a new salt value. + rawSalt := make([]byte, 24) + var n int + if n, err = io.ReadFull(rand.Reader, rawSalt); nil != err { + return + } + if len(rawSalt) != n { + err = errors.New("failed to create random password salt") + return + } + + // Store salt and encrypted password. + c.Salt = base64.StdEncoding.EncodeToString(rawSalt) + c.Password, err = c.encrypt(password, c.Salt) + return +} + +// encrypt a password/salt combination to the encrypted password. +func (c *credential) encrypt(password, salt string) (enc string, err error) { + // Decode salt from Base64 to raw bytes. + var rawSalt []byte + if rawSalt, err = base64.StdEncoding.DecodeString(salt); nil != err { + return + } + + // Encrypt password to bytes. Encryption values determined by current + // cryptography suggestion. + var rawEnc []byte + if rawEnc, err = scrypt.Key( + []byte(password), rawSalt, 16384, 8, 1, 32, + ); nil != err { + return + } + + // Encode as Base64. + enc = base64.StdEncoding.EncodeToString(rawEnc) + return +} + +func authAddMain(command string, args []string) (err error) { + if 0 == len(args) || 2 < len(args) { + return fmt.Errorf( + "wrong number of arguments supplied to: '%s add', try '%s help'", + command, command, + ) + } + username := args[0] + var password string + if 1 < len(args) { + password = args[1] + } + + if err = loadCredentials(envvar.credentialFile); nil != err { + fmt.Printf( + "WARNING: Credential file '%s' doesn't already exist... creating.\n", + envvar.credentialFile, + ) + err = nil + } + if _, found := credentials[username]; found { + return fmt.Errorf( + "user '%s' already exists, use 'update' in place of 'add'", + username, + ) + } + if 0 == len(password) { + if password, err = getPassword(); nil != err { + return + } + } + cred := &credential{} + if err = cred.update(password); nil != err { + return + } + credentials[username] = cred + return saveCredentials(envvar.credentialFile) +} + +func authListMain(command string, args []string) (err error) { + if 0 != len(args) { + return fmt.Errorf( + "'%s list' does not accept any arguments, try '%s help'", + command, command, + ) + } + + if err = loadCredentials(envvar.credentialFile); nil != err { + return + } + for username := range credentials { + fmt.Println(username) + } + return +} + +func authRemoveMain(command string, args []string) (err error) { + if 1 != len(args) { + return fmt.Errorf( + "'%s remove' requires exactly one argument, try '%s help'", + command, command, + ) + } + + username := args[0] + if err = loadCredentials(envvar.credentialFile); nil != err { + return + } + if _, found := credentials[username]; !found { + return fmt.Errorf("user '%s' doesn't exist", username) + } + delete(credentials, username) + return saveCredentials(envvar.credentialFile) +} + +func authUpdateMain(command string, args []string) (err error) { + if 0 == len(args) || 2 < len(args) { + return fmt.Errorf( + "wrong number of arguments supplied to: '%s update', try '%s help'", + command, command, + ) + } + username := args[0] + var password string + if 1 < len(args) { + password = args[1] + } + + if err = loadCredentials(envvar.credentialFile); nil != err { + return + } + cred, found := credentials[username] + if !found { + return fmt.Errorf( + "user '%s' doesn't exist, use 'add' in place of 'update'", + username, + ) + } + if 0 == len(password) { + if password, err = getPassword(); nil != err { + return + } + } + if err = cred.update(password); nil != err { + return + } + return saveCredentials(envvar.credentialFile) +} + +func authMain(command string, args []string) { + // Subcommand not supplied. Redirect to help. + if 0 == len(args) { + log.Fatalf( + "no arguments supplied to: '%s', try '%s help'", + command, command, + ) + } + + subCommand := args[0] + args = args[1:] + + if strings.Contains(subCommand, "help") { + fmt.Println(authHelp) + return + } + + if 0 == len(envvar.credentialFile) { + log.Fatalln("credential file required but not set") + } + + var err error + switch subCommand { + case "add": + err = authAddMain(command, args) + case "list": + err = authListMain(command, args) + case "remove": + err = authRemoveMain(command, args) + case "update": + err = authUpdateMain(command, args) + default: + err = fmt.Errorf( + "unrecognized command '%s %s', try '%s help'", + command, subCommand, command, + ) + } + if nil != err { + log.Fatalln(err) + } +} + func main() { + + // Collect environment variables. + envvar.credentialFile = env("CREDENTIALS", "") + envvar.fastAuth = env("FAST_AUTH", "") + envvar.folder = env("FOLDER", "/web") + "/" + envvar.host = env("HOST", "") + envvar.port = env("PORT", "8080") + envvar.showListing = envAsBool("SHOW_LISTING", true) + envvar.tlsCert = env("TLS_CERT", "") + envvar.tlsKey = env("TLS_KEY", "") + envvar.urlPrefix = env("URL_PREFIX", "") + // Evaluate and execute subcommand if supplied. + appName := os.Args[0] if 1 < len(os.Args) { arg := os.Args[1] + command := fmt.Sprintf("%s %s", appName, arg) switch { case strings.Contains(arg, "help"): fmt.Println(help) case strings.Contains(arg, "version"): fmt.Println(version) + case "auth" == arg: + authMain(command, os.Args[2:]) default: - name := os.Args[0] - log.Fatalf("Unknown argument: %s. Try '%s help'.", arg, name) + log.Fatalf("Unknown argument: %s. Try '%s help'.", arg, appName) } return } - // Collect environment variables. - folder := env("FOLDER", "/web") + "/" - host := env("HOST", "") - port := env("PORT", "8080") - showListing := envAsBool("SHOW_LISTING", true) - tlsCert := env("TLS_CERT", "") - tlsKey := env("TLS_KEY", "") - urlPrefix := env("URL_PREFIX", "") - // If HTTPS is to be used, verify both TLS_* environment variables are set. - if 0 < len(tlsCert) || 0 < len(tlsKey) { - if 0 == len(tlsCert) || 0 == len(tlsKey) { + if 0 < len(envvar.tlsCert) || 0 < len(envvar.tlsKey) { + if 0 == len(envvar.tlsCert) || 0 == len(envvar.tlsKey) { log.Fatalln( "If value for environment variable 'TLS_CERT' or 'TLS_KEY' is set " + "then value for environment variable 'TLS_KEY' or 'TLS_CERT' must " + @@ -146,28 +509,80 @@ func main() { } // If the URL path prefix is to be used, verify it is properly formatted. - if 0 < len(urlPrefix) && - (!strings.HasPrefix(urlPrefix, "/") || strings.HasSuffix(urlPrefix, "/")) { + if 0 < len(envvar.urlPrefix) && + (!strings.HasPrefix(envvar.urlPrefix, "/") || strings.HasSuffix(envvar.urlPrefix, "/")) { log.Fatalln( "Value for environment variable 'URL_PREFIX' must start " + "with '/' and not end with '/'. Example: '/my/prefix'", ) } + // Determine whether basic authentication is needed. + auth := func(handler http.HandlerFunc) http.HandlerFunc { + return handler + } + if 0 < len(envvar.credentialFile) || 0 < len(envvar.fastAuth) { + if 0 < len(envvar.credentialFile) { + if err := loadCredentials(envvar.credentialFile); nil != err { + log.Fatalln(err) + } + } else { + credentials = make(map[string]*credential) + parts := strings.Split(envvar.fastAuth, ":") + if 2 != len(parts) || 0 == len(parts[0]) || 0 == len(parts[1]) { + log.Fatalln( + "'FAST_AUTH' must have exactly one colon (:) to separate " + + "the username from the password (username:password)", + ) + } + cred := &credential{} + if err := cred.update(parts[1]); nil != err { + log.Fatalln(err) + } + credentials[parts[0]] = cred + } + auth = func(handler http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + defer r.Body.Close() + username, password, ok := r.BasicAuth() + if !ok || 0 == len(username) || 0 == len(password) { + w.WriteHeader(http.StatusForbidden) + return + } + cred, ok := credentials[username] + if !ok { + w.WriteHeader(http.StatusForbidden) + return + } + allowed, err := cred.matches(password) + if nil != err { + log.Println(err) + w.WriteHeader(http.StatusForbidden) + return + } + if !allowed { + w.WriteHeader(http.StatusForbidden) + return + } + handler(w, r) + } + } + } + // Choose and set the appropriate, optimized static file serving function. var handler http.HandlerFunc - if 0 == len(urlPrefix) { - handler = handleListing(showListing, basicHandler(folder)) + if 0 == len(envvar.urlPrefix) { + handler = handleListing(envvar.showListing, basicHandler(envvar.folder)) } else { - handler = handleListing(showListing, prefixHandler(folder, urlPrefix)) + handler = handleListing(envvar.showListing, prefixHandler(envvar.folder, envvar.urlPrefix)) } - http.HandleFunc("/", handler) + http.HandleFunc("/", auth(handler)) // Serve files over HTTP or HTTPS based on paths to TLS files being provided. - if 0 == len(tlsCert) { - log.Fatalln(http.ListenAndServe(host+":"+port, nil)) + if 0 == len(envvar.tlsCert) { + log.Fatalln(http.ListenAndServe(envvar.host+":"+envvar.port, nil)) } else { - log.Fatalln(http.ListenAndServeTLS(host+":"+port, tlsCert, tlsKey, nil)) + log.Fatalln(http.ListenAndServeTLS(envvar.host+":"+envvar.port, envvar.tlsCert, envvar.tlsKey, nil)) } } @@ -176,7 +591,7 @@ func main() { // will attempt to retrieve the index file of that directory. func handleListing(show bool, serve http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if show || strings.HasSuffix(r.URL.Path, "/") { + if !show && strings.HasSuffix(r.URL.Path, "/") { http.NotFound(w, r) return } @@ -203,6 +618,58 @@ func prefixHandler(folder, urlPrefix string) http.HandlerFunc { } } +// loadCredentials from an existing credentials file. +func loadCredentials(filename string) (err error) { + credentials = make(map[string]*credential) + if 0 == len(filename) { + return errors.New("credential file name not set but is required") + } + var contents []byte + if contents, err = ioutil.ReadFile(filename); nil != err { + return + } + return json.Unmarshal(contents, &credentials) +} + +// saveCredentials to the specified credentials file. +func saveCredentials(filename string) (err error) { + if 0 == len(filename) { + return errors.New("credential file name not set but is required") + } + var contents []byte + if contents, err = json.Marshal(&credentials); nil != err { + return + } + return ioutil.WriteFile(filename, contents, 0600) +} + +// getPassword from the user via the terminal. Mask all characters. +func getPassword() (password string, err error) { + maskInput := true + var rawPassword []byte + var rawConfirmPassword []byte + if rawPassword, err = gopass.GetPasswdPrompt( + "New password:", maskInput, os.Stdin, os.Stdout, + ); nil != err { + return + } + if 0 == len(rawPassword) { + err = errors.New("password may not be empty") + return + } + if rawConfirmPassword, err = gopass.GetPasswdPrompt( + "Confirm password:", maskInput, os.Stdin, os.Stdout, + ); nil != err { + return + } + if !bytes.Equal(rawPassword, rawConfirmPassword) { + err = errors.New("passwords do not match") + return + } + password = string(rawPassword) + return +} + // env returns the value for an environment variable or, if not set, a fallback // value. func env(key, fallback string) string {