package httpserver import ( "compress/gzip" "crypto/rand" "encoding/base64" "encoding/json" "html/template" "io" "io/fs" "io/ioutil" "mime" "net/http" "path" "path/filepath" "regexp" "strings" "time" "github.com/improbable-eng/grpc-web/go/grpcweb" "go.uber.org/zap" "golang.org/x/oauth2" "go.c3c.cz/cv/app/server/internal/files" "go.c3c.cz/cv/app/server/internal/version" ) const refreshTokenCookieName = "rt" type handler struct { oauth2 *oauth2.Config indexTemplate *template.Template looseCORS bool grpcWebServer *grpcweb.WrappedGrpcServer frontendConfig string logger *zap.Logger refreshTokenCookieDomain string } type accessToken struct { Token string `json:"token"` ExpiresIn int64 `json:"expiresIn"` } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.looseCORS { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, x-grpc-web, Authorization, x-user-agent") } switch { case h.grpcWebServer.IsGrpcWebRequest(r): h.grpcWebServer.ServeHTTP(w, r) case r.Method == http.MethodPost && r.URL.Path == "/auth/login": h.handleLogin(w, r) case r.Method == http.MethodPost && r.URL.Path == "/auth/logout": h.handleLogout(w, r) case r.Method == http.MethodPost && r.URL.Path == "/auth/refresh": h.handleRefreshToken(w, r) default: f, err := files.Public.Open(path.Join("data", "public", r.URL.Path)) if err != nil { h.serveIndex(w) return } fi, err := f.Stat() if err != nil || fi.IsDir() { h.serveIndex(w) return } h.serveFile(w, r, version.CommitTime, f) } } func (h *handler) serveFile(w http.ResponseWriter, r *http.Request, modtime time.Time, f fs.File) { if !modtime.IsZero() { ims := r.Header.Get("If-Modified-Since") modtime = modtime.Truncate(time.Second) if t, err := http.ParseTime(ims); err == nil && !modtime.After(t) { w.WriteHeader(http.StatusNotModified) return } w.Header().Set("Last-Modified", modtime.UTC().Format(http.TimeFormat)) } w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(r.URL.Path))) if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { w.Header().Set("Content-Encoding", "gzip") gw, err := gzip.NewWriterLevel(w, gzip.DefaultCompression) if err != nil { h.logger.Warn("Could not create gzip writer", zap.Error(err)) return } if n, _ := io.Copy(gw, f); n == 0 { w.Header().Del("Content-Encoding") gw.Reset(ioutil.Discard) } err = gw.Close() if err != nil { h.logger.Warn("Could not close gzip writer", zap.Error(err)) return } } else { _, err := io.Copy(w, f) if err != nil { h.logger.Warn("Could not write file response", zap.Error(err)) return } } } func (h *handler) serveIndex(w http.ResponseWriter) { w.Header().Set("Content-Type", "text/html; charset=utf-8") if h.indexTemplate == nil { _, _ = w.Write([]byte("no index")) return } token := make([]byte, 12) _, err := rand.Read(token) if err != nil { h.logger.Warn("Could not create random csp token", zap.Error(err)) return } data := indexData{ Config: h.frontendConfig, CspNonce: base64.RawStdEncoding.EncodeToString(token), } w.Header().Set("Content-Security-Policy", "script-src 'self' 'nonce-"+data.CspNonce+"' 'unsafe-eval'") w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("X-XSS-Protection", "1; mode=block") w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Referrer-Policy", "same-origin") w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") w.Header().Set("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=()") err = h.indexTemplate.Execute(w, data) if err != nil { h.logger.Warn("Could not execute index template", zap.Error(err)) } } type authData struct { Email string Password string } func (h *handler) handleLogin(w http.ResponseWriter, r *http.Request) { var ad authData if err := json.NewDecoder(r.Body).Decode(&ad); err != nil { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusBadRequest) err = json.NewEncoder(w).Encode(map[string]string{"error": "invalid json"}) if err != nil { h.logger.Error("Could not encode json response with invalid json error", zap.Error(err)) } return } r.Body.Close() tok, err := h.oauth2.PasswordCredentialsToken(r.Context(), ad.Email, ad.Password) if err != nil { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusUnauthorized) err = json.NewEncoder(w).Encode(map[string]string{"error": "wrong credentials"}) if err != nil { h.logger.Error("Could not encode json response with wrong credentials error", zap.Error(err)) } return } if tok.RefreshToken != "" { http.SetCookie(w, &http.Cookie{ Domain: h.refreshTokenCookieDomain, Path: "/auth/refresh", Name: refreshTokenCookieName, Value: tok.RefreshToken, HttpOnly: true, SameSite: http.SameSiteStrictMode, Secure: isSecure(r), }) } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) err = json.NewEncoder(w).Encode(accessToken{ Token: tok.AccessToken, ExpiresIn: int64(time.Until(tok.Expiry) / time.Second), }) if err != nil { h.logger.Error("Could not encode access token json", zap.Error(err)) } } func (h *handler) handleRefreshToken(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(refreshTokenCookieName) if err != nil { w.WriteHeader(http.StatusUnauthorized) return } tok, err := h.oauth2.TokenSource(r.Context(), &oauth2.Token{RefreshToken: cookie.Value}).Token() if err != nil { w.WriteHeader(http.StatusUnauthorized) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(http.StatusOK) err = json.NewEncoder(w).Encode(accessToken{ Token: tok.AccessToken, ExpiresIn: int64(time.Until(tok.Expiry) / time.Second), }) if err != nil { h.logger.Error("Could not encode refresh token response", zap.Error(err)) } } func (h *handler) handleLogout(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Domain: h.refreshTokenCookieDomain, Path: "/auth/refresh", Name: refreshTokenCookieName, MaxAge: -1, HttpOnly: true, Secure: isSecure(r), }) w.WriteHeader(http.StatusOK) _, err := w.Write([]byte("ok")) if err != nil { h.logger.Error("Could not write logout response", zap.Error(err)) } } type indexData struct { Config string CspNonce string } var ( httpHeaderXForwardedProto = http.CanonicalHeaderKey("X-Forwarded-Proto") httpHeaderXForwardedScheme = http.CanonicalHeaderKey("X-Forwarded-Scheme") httpHeaderForwarded = http.CanonicalHeaderKey("Forwarded") protoRegex = regexp.MustCompile(`(?i)(?:proto=)(https|http)`) ) func isSecure(r *http.Request) bool { if strings.EqualFold(r.URL.Scheme, "https") { return true } var scheme string if proto := r.Header.Get(httpHeaderXForwardedProto); proto != "" { scheme = strings.ToLower(proto) } else if proto = r.Header.Get(httpHeaderXForwardedScheme); proto != "" { scheme = strings.ToLower(proto) } else if proto = r.Header.Get(httpHeaderForwarded); proto != "" { if match := protoRegex.FindStringSubmatch(proto); len(match) > 1 { scheme = strings.ToLower(match[1]) } } return scheme == "https" }