diff --git a/.gitignore b/.gitignore index 2e785fd..f792599 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ play-with-docker node_modules docker-compose.single.yml +lala diff --git a/config/config.go b/config/config.go index 5384758..5538b6c 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,12 @@ import ( "os" "regexp" "time" + + "github.com/gorilla/securecookie" + + "golang.org/x/oauth2" + oauth2FB "golang.org/x/oauth2/facebook" + oauth2Github "golang.org/x/oauth2/github" ) const ( @@ -21,12 +27,17 @@ const ( var NameFilter = regexp.MustCompile(PWDHostPortGroupRegex) var AliasFilter = regexp.MustCompile(AliasPortGroupRegex) -var PortNumber, Key, Cert, SessionsFile, PWDContainerName, L2ContainerName, L2Subdomain, PWDCName, HashKey, SSHKeyPath, L2RouterIP, DindVolumeSize string +var PortNumber, Key, Cert, SessionsFile, PWDContainerName, L2ContainerName, L2Subdomain, PWDCName, HashKey, SSHKeyPath, L2RouterIP, DindVolumeSize, CookieHashKey, CookieBlockKey string var UseLetsEncrypt, ExternalDindVolume, NoWindows bool var LetsEncryptCertsDir string var LetsEncryptDomains stringslice var MaxLoadAvg float64 var ForceTLS bool +var Providers map[string]*oauth2.Config +var SecureCookie *securecookie.SecureCookie + +var GithubClientID, GithubClientSecret string +var FacebookClientID, FacebookClientSecret string type stringslice []string @@ -58,8 +69,46 @@ func ParseFlags() { flag.BoolVar(&ExternalDindVolume, "external-dind-volume", false, "Use external dind volume") flag.Float64Var(&MaxLoadAvg, "maxload", 100, "Maximum allowed load average before failing ping requests") flag.StringVar(&SSHKeyPath, "ssh_key_path", "", "SSH Private Key to use") + flag.StringVar(&CookieHashKey, "cookie-hash-key", "", "Hash key to use to validate cookies") + flag.StringVar(&CookieBlockKey, "cookie-block-key", "", "Block key to use to encrypt cookies") + + flag.StringVar(&GithubClientID, "github-client-id", "", "Github OAuth Client ID") + flag.StringVar(&GithubClientSecret, "github-client-secret", "", "Github OAuth Client Secret") + + flag.StringVar(&FacebookClientID, "facebook-client-id", "", "Facebook OAuth Client ID") + flag.StringVar(&FacebookClientSecret, "facebook-client-secret", "", "Facebook OAuth Client Secret") + flag.Parse() + + SecureCookie = securecookie.New([]byte(CookieHashKey), []byte(CookieBlockKey)) + + registerOAuthProviders() } + +func registerOAuthProviders() { + Providers = map[string]*oauth2.Config{} + if GithubClientID != "" && GithubClientSecret != "" { + conf := &oauth2.Config{ + ClientID: GithubClientID, + ClientSecret: GithubClientSecret, + Scopes: []string{"user:email"}, + Endpoint: oauth2Github.Endpoint, + } + + Providers["github"] = conf + } + if FacebookClientID != "" && FacebookClientSecret != "" { + conf := &oauth2.Config{ + ClientID: FacebookClientID, + ClientSecret: FacebookClientSecret, + Scopes: []string{"email", "public_profile"}, + Endpoint: oauth2FB.Endpoint, + } + + Providers["facebook"] = conf + } +} + func GetDindImageName() string { dindImage := os.Getenv("DIND_IMAGE") defaultDindImageName := "franela/dind" diff --git a/handlers/bootstrap.go b/handlers/bootstrap.go index 3b8bada..a61227c 100644 --- a/handlers/bootstrap.go +++ b/handlers/bootstrap.go @@ -5,7 +5,6 @@ import ( "fmt" "log" "net/http" - "os" "time" "golang.org/x/crypto/acme/autocert" @@ -16,7 +15,6 @@ import ( "github.com/play-with-docker/play-with-docker/config" "github.com/play-with-docker/play-with-docker/event" "github.com/play-with-docker/play-with-docker/pwd" - "github.com/play-with-docker/play-with-docker/templates" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/urfave/negroni" ) @@ -33,9 +31,6 @@ func Bootstrap(c pwd.PWDApi, ev event.EventApi) { } func Register(extend HandlerExtender) { - - bypassCaptcha := len(os.Getenv("GOOGLE_RECAPTCHA_DISABLED")) > 0 - server, err := socketio.NewServer(nil) if err != nil { log.Fatal(err) @@ -81,17 +76,13 @@ func Register(extend HandlerExtender) { // Generic routes r.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) { - if bypassCaptcha { - http.ServeFile(rw, r, "./www/bypass.html") - } else { - welcome, tmplErr := templates.GetWelcomeTemplate() - if tmplErr != nil { - log.Fatal(tmplErr) - } - rw.Write(welcome) - } + http.ServeFile(rw, r, "./www/landing.html") }).Methods("GET") + r.HandleFunc("/oauth/providers", ListProviders).Methods("GET") + r.HandleFunc("/oauth/providers/{provider}/login", Login).Methods("GET") + r.HandleFunc("/oauth/providers/{provider}/callback", LoginCallback).Methods("GET") + corsRouter.HandleFunc("/", NewSession).Methods("POST") if extend != nil { diff --git a/handlers/cookie_id.go b/handlers/cookie_id.go new file mode 100644 index 0000000..c19b25a --- /dev/null +++ b/handlers/cookie_id.go @@ -0,0 +1,40 @@ +package handlers + +import ( + "net/http" + + "github.com/play-with-docker/play-with-docker/config" +) + +type CookieID struct { + Id string `json:"id"` + UserName string `json:"user_name"` + UserAvatar string `json:"user_avatar"` +} + +func (c *CookieID) SetCookie(rw http.ResponseWriter) error { + if encoded, err := config.SecureCookie.Encode("id", c); err == nil { + cookie := &http.Cookie{ + Name: "id", + Value: encoded, + Path: "/", + Secure: config.UseLetsEncrypt, + } + http.SetCookie(rw, cookie) + } else { + return err + } + return nil +} +func ReadCookie(r *http.Request) (*CookieID, error) { + if cookie, err := r.Cookie("id"); err == nil { + value := &CookieID{} + if err = config.SecureCookie.Decode("id", cookie.Value, &value); err == nil { + return value, nil + } else { + return nil, err + } + } else { + return nil, err + } +} diff --git a/handlers/login.go b/handlers/login.go new file mode 100644 index 0000000..47a4c67 --- /dev/null +++ b/handlers/login.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "strconv" + + "golang.org/x/oauth2" + + "github.com/google/go-github/github" + "github.com/gorilla/mux" + fb "github.com/huandu/facebook" + "github.com/play-with-docker/play-with-docker/config" + "github.com/play-with-docker/play-with-docker/pwd/types" +) + +func ListProviders(rw http.ResponseWriter, req *http.Request) { + providers := []string{} + for name, _ := range config.Providers { + providers = append(providers, name) + } + json.NewEncoder(rw).Encode(providers) +} + +func Login(rw http.ResponseWriter, req *http.Request) { + vars := mux.Vars(req) + providerName := vars["provider"] + + provider, found := config.Providers[providerName] + if !found { + log.Printf("Could not find provider %s\n", providerName) + rw.WriteHeader(http.StatusNotFound) + return + } + + loginRequest, err := core.UserNewLoginRequest(providerName) + if err != nil { + log.Printf("Could not start a new user login request for provider %s. Got: %v\n", providerName, err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + + scheme := "http" + if req.URL.Scheme != "" { + scheme = req.URL.Scheme + } + host := "localhost" + if req.URL.Host != "" { + host = req.URL.Host + } + provider.RedirectURL = fmt.Sprintf("%s://%s/oauth/providers/%s/callback", scheme, host, providerName) + url := provider.AuthCodeURL(loginRequest.Id) + + http.Redirect(rw, req, url, http.StatusFound) +} + +func LoginCallback(rw http.ResponseWriter, req *http.Request) { + vars := mux.Vars(req) + providerName := vars["provider"] + + provider, found := config.Providers[providerName] + if !found { + log.Printf("Could not find provider %s\n", providerName) + rw.WriteHeader(http.StatusNotFound) + return + } + + query := req.URL.Query() + + code := query.Get("code") + loginRequestId := query.Get("state") + + loginRequest, err := core.UserGetLoginRequest(loginRequestId) + if err != nil { + log.Printf("Could not get login request %s for provider %s. Got: %v\n", loginRequestId, providerName, err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + + ctx := req.Context() + tok, err := provider.Exchange(ctx, code) + if err != nil { + log.Printf("Could not exchage code for access token for provider %s. Got: %v\n", providerName, err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + + user := &types.User{Provider: providerName} + if providerName == "github" { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: tok.AccessToken}, + ) + tc := oauth2.NewClient(ctx, ts) + client := github.NewClient(tc) + u, _, err := client.Users.Get(ctx, "") + if err != nil { + log.Printf("Could not get user from github. Got: %v\n", err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + user.ProviderUserId = strconv.Itoa(u.GetID()) + user.Name = u.GetName() + user.Avatar = u.GetAvatarURL() + user.Email = u.GetEmail() + } else if providerName == "facebook" { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: tok.AccessToken}, + ) + tc := oauth2.NewClient(ctx, ts) + session := &fb.Session{ + Version: "v2.10", + HttpClient: tc, + } + p := fb.Params{} + p["fields"] = "email,name,picture" + res, err := session.Get("/me", p) + if err != nil { + log.Printf("Could not get user from facebook. Got: %v\n", err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + user.ProviderUserId = res.Get("id").(string) + user.Name = res.Get("name").(string) + user.Avatar = res.Get("picture.data.url").(string) + user.Email = res.Get("email").(string) + } + + user, err = core.UserLogin(loginRequest, user) + if err != nil { + log.Printf("Could not login user. Got: %v\n", err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + + cookieData := CookieID{Id: user.Id, UserName: user.Name, UserAvatar: user.Avatar} + + if err := cookieData.SetCookie(rw); err != nil { + log.Printf("Could not encode cookie. Got: %v\n", err) + rw.WriteHeader(http.StatusInternalServerError) + return + } + + http.Redirect(rw, req, "/", http.StatusFound) +} diff --git a/handlers/new_session.go b/handlers/new_session.go index 16bad1f..8afbe31 100644 --- a/handlers/new_session.go +++ b/handlers/new_session.go @@ -10,7 +10,6 @@ import ( "github.com/play-with-docker/play-with-docker/config" "github.com/play-with-docker/play-with-docker/provisioner" - "github.com/play-with-docker/play-with-docker/recaptcha" ) type NewSessionResponse struct { @@ -20,10 +19,16 @@ type NewSessionResponse struct { func NewSession(rw http.ResponseWriter, req *http.Request) { req.ParseForm() - if !recaptcha.IsHuman(req, rw) { - // User it not a human - rw.WriteHeader(http.StatusForbidden) - return + + userId := "" + if len(config.Providers) > 0 { + cookie, err := ReadCookie(req) + if err != nil { + // User it not a human + rw.WriteHeader(http.StatusForbidden) + return + } + userId = cookie.Id } reqDur := req.Form.Get("session-duration") @@ -45,7 +50,7 @@ func NewSession(rw http.ResponseWriter, req *http.Request) { } duration := config.GetDuration(reqDur) - s, err := core.SessionNew(duration, stack, stackName, imageName) + s, err := core.SessionNew(userId, duration, stack, stackName, imageName) if err != nil { if provisioner.OutOfCapacity(err) { http.Redirect(rw, req, "/ooc", http.StatusFound) diff --git a/pwd/client_test.go b/pwd/client_test.go index f661567..237c734 100644 --- a/pwd/client_test.go +++ b/pwd/client_test.go @@ -43,7 +43,7 @@ func TestClientNew(t *testing.T) { p := NewPWD(_f, _e, _s, sp, ipf) p.generator = _g - session, err := p.SessionNew(time.Hour, "", "", "") + session, err := p.SessionNew("", time.Hour, "", "", "") assert.Nil(t, err) client := p.ClientNew("foobar", session) @@ -82,7 +82,7 @@ func TestClientCount(t *testing.T) { p := NewPWD(_f, _e, _s, sp, ipf) p.generator = _g - session, err := p.SessionNew(time.Hour, "", "", "") + session, err := p.SessionNew("", time.Hour, "", "", "") assert.Nil(t, err) p.ClientNew("foobar", session) @@ -123,7 +123,7 @@ func TestClientResizeViewPort(t *testing.T) { p := NewPWD(_f, _e, _s, sp, ipf) p.generator = _g - session, err := p.SessionNew(time.Hour, "", "", "") + session, err := p.SessionNew("", time.Hour, "", "", "") assert.Nil(t, err) client := p.ClientNew("foobar", session) _s.On("ClientFindBySessionId", "aaaabbbbcccc").Return([]*types.Client{client}, nil) diff --git a/pwd/instance_test.go b/pwd/instance_test.go index 3e848e6..e8277ef 100644 --- a/pwd/instance_test.go +++ b/pwd/instance_test.go @@ -70,7 +70,7 @@ func TestInstanceNew(t *testing.T) { p := NewPWD(_f, _e, _s, sp, ipf) p.generator = _g - session, err := p.SessionNew(time.Hour, "", "", "") + session, err := p.SessionNew("", time.Hour, "", "", "") assert.Nil(t, err) expectedInstance := types.Instance{ @@ -138,7 +138,7 @@ func TestInstanceNew_WithNotAllowedImage(t *testing.T) { p := NewPWD(_f, _e, _s, sp, ipf) p.generator = _g - session, err := p.SessionNew(time.Hour, "", "", "") + session, err := p.SessionNew("", time.Hour, "", "", "") assert.Nil(t, err) @@ -207,7 +207,7 @@ func TestInstanceNew_WithCustomHostname(t *testing.T) { p := NewPWD(_f, _e, _s, sp, ipf) p.generator = _g - session, err := p.SessionNew(time.Hour, "", "", "") + session, err := p.SessionNew("", time.Hour, "", "", "") assert.Nil(t, err) expectedInstance := types.Instance{ diff --git a/pwd/mock.go b/pwd/mock.go index cb46a06..c417b4c 100644 --- a/pwd/mock.go +++ b/pwd/mock.go @@ -13,7 +13,7 @@ type Mock struct { mock.Mock } -func (m *Mock) SessionNew(duration time.Duration, stack string, stackName, imageName string) (*types.Session, error) { +func (m *Mock) SessionNew(userId string, duration time.Duration, stack string, stackName, imageName string) (*types.Session, error) { args := m.Called(duration, stack, stackName, imageName) return args.Get(0).(*types.Session), args.Error(1) } @@ -104,3 +104,18 @@ func (m *Mock) ClientCount() int { args := m.Called() return args.Int(0) } + +func (m *Mock) UserNewLoginRequest(providerName string) (*types.LoginRequest, error) { + args := m.Called(providerName) + return args.Get(0).(*types.LoginRequest), args.Error(1) +} + +func (m *Mock) UserGetLoginRequest(id string) (*types.LoginRequest, error) { + args := m.Called(id) + return args.Get(0).(*types.LoginRequest), args.Error(1) +} + +func (m *Mock) UserLogin(loginRequest *types.LoginRequest, user *types.User) (*types.User, error) { + args := m.Called(loginRequest, user) + return args.Get(0).(*types.User), args.Error(1) +} diff --git a/pwd/pwd.go b/pwd/pwd.go index a18815d..5aaf2e5 100644 --- a/pwd/pwd.go +++ b/pwd/pwd.go @@ -72,7 +72,7 @@ func SessionNotEmpty(e error) bool { } type PWDApi interface { - SessionNew(duration time.Duration, stack string, stackName, imageName string) (*types.Session, error) + SessionNew(userId string, duration time.Duration, stack string, stackName, imageName string) (*types.Session, error) SessionClose(session *types.Session) error SessionGetSmallestViewPort(sessionId string) types.ViewPort SessionDeployStack(session *types.Session) error @@ -93,6 +93,10 @@ type PWDApi interface { ClientResizeViewPort(client *types.Client, cols, rows uint) ClientClose(client *types.Client) ClientCount() int + + UserNewLoginRequest(providerName string) (*types.LoginRequest, error) + UserGetLoginRequest(id string) (*types.LoginRequest, error) + UserLogin(loginRequest *types.LoginRequest, user *types.User) (*types.User, error) } func NewPWD(f docker.FactoryApi, e event.EventApi, s storage.StorageApi, sp provisioner.SessionProvisionerApi, ipf provisioner.InstanceProvisionerFactoryApi) *pwd { diff --git a/pwd/session.go b/pwd/session.go index a4c8d7a..75bbf01 100644 --- a/pwd/session.go +++ b/pwd/session.go @@ -44,7 +44,7 @@ type SessionSetupInstanceConf struct { Tls bool `json:"tls"` } -func (p *pwd) SessionNew(duration time.Duration, stack, stackName, imageName string) (*types.Session, error) { +func (p *pwd) SessionNew(userId string, duration time.Duration, stack, stackName, imageName string) (*types.Session, error) { defer observeAction("SessionNew", time.Now()) s := &types.Session{} @@ -53,6 +53,7 @@ func (p *pwd) SessionNew(duration time.Duration, stack, stackName, imageName str s.ExpiresAt = s.CreatedAt.Add(duration) s.Ready = true s.Stack = stack + s.UserId = userId if s.Stack != "" { s.Ready = false diff --git a/pwd/session_test.go b/pwd/session_test.go index c3bc528..ab07e1b 100644 --- a/pwd/session_test.go +++ b/pwd/session_test.go @@ -45,7 +45,7 @@ func TestSessionNew(t *testing.T) { before := time.Now() - s, e := p.SessionNew(time.Hour, "", "", "") + s, e := p.SessionNew("", time.Hour, "", "", "") assert.Nil(t, e) assert.NotNil(t, s) @@ -56,7 +56,7 @@ func TestSessionNew(t *testing.T) { assert.WithinDuration(t, s.ExpiresAt, before.Add(time.Hour), time.Second) assert.True(t, s.Ready) - s, _ = p.SessionNew(time.Hour, "stackPath", "stackName", "imageName") + s, _ = p.SessionNew("", time.Hour, "stackPath", "stackName", "imageName") assert.Equal(t, "stackPath", s.Stack) assert.Equal(t, "stackName", s.StackName) diff --git a/pwd/types/session.go b/pwd/types/session.go index eeb0563..1322e77 100644 --- a/pwd/types/session.go +++ b/pwd/types/session.go @@ -15,6 +15,7 @@ type Session struct { StackName string `json:"stack_name" bson:"stack_name"` ImageName string `json:"image_name" bson:"image_name"` Host string `json:"host" bson:"host"` + UserId string `json:"user_id" bson:"user_id"` rw sync.Mutex `json:"-"` } diff --git a/pwd/types/user.go b/pwd/types/user.go new file mode 100644 index 0000000..ccabcd5 --- /dev/null +++ b/pwd/types/user.go @@ -0,0 +1,15 @@ +package types + +type User struct { + Id string `json:"id" bson:"id"` + Name string `json:"name" bson:"name"` + ProviderUserId string `json:"provider_user_id" bson:"provider_user_id"` + Avatar string `json:"avatar" bson:"avatar"` + Provider string `json:"provider" bson:"provider"` + Email string `json:"email" bson:"email"` +} + +type LoginRequest struct { + Id string `json:"id" bson:"id"` + Provider string `json:"provider" bson:"provider"` +} diff --git a/pwd/user.go b/pwd/user.go new file mode 100644 index 0000000..985e1e8 --- /dev/null +++ b/pwd/user.go @@ -0,0 +1,43 @@ +package pwd + +import ( + "github.com/play-with-docker/play-with-docker/pwd/types" + "github.com/play-with-docker/play-with-docker/storage" +) + +func (p *pwd) UserNewLoginRequest(providerName string) (*types.LoginRequest, error) { + req := &types.LoginRequest{Id: p.generator.NewId(), Provider: providerName} + if err := p.storage.LoginRequestPut(req); err != nil { + return nil, err + } + return req, nil +} + +func (p *pwd) UserGetLoginRequest(id string) (*types.LoginRequest, error) { + if req, err := p.storage.LoginRequestGet(id); err != nil { + return nil, err + } else { + return req, nil + } +} + +func (p *pwd) UserLogin(loginRequest *types.LoginRequest, user *types.User) (*types.User, error) { + if err := p.storage.LoginRequestDelete(loginRequest.Id); err != nil { + return nil, err + } + u, err := p.storage.UserFindByProvider(user.Provider, user.ProviderUserId) + if err != nil { + if storage.NotFound(err) { + user.Id = p.generator.NewId() + } else { + return nil, err + } + } else { + user.Id = u.Id + } + if err := p.storage.UserPut(user); err != nil { + return nil, err + } + + return user, nil +} diff --git a/recaptcha/recaptcha.go b/recaptcha/recaptcha.go deleted file mode 100644 index 66b1558..0000000 --- a/recaptcha/recaptcha.go +++ /dev/null @@ -1,90 +0,0 @@ -package recaptcha - -import ( - "encoding/json" - "fmt" - "log" - "net/http" - "net/url" - "os" - "strings" - "time" - - "github.com/gorilla/securecookie" - "github.com/play-with-docker/play-with-docker/config" - "github.com/rs/xid" -) - -func GetGoogleRecaptchaSiteKey() string { - key := os.Getenv("GOOGLE_RECAPTCHA_SITE_KEY") - if key == "" { - // This is a development default. The environment variable should always be set in production. - key = "6LeY_QsUAAAAAOlpVw4MhoLEr50h-dM80oz6M2AX" - } - return key -} -func GetGoogleRecaptchaSiteSecret() string { - key := os.Getenv("GOOGLE_RECAPTCHA_SITE_SECRET") - if key == "" { - // This is a development default. The environment variable should always be set in production. - key = "6LeY_QsUAAAAAHIALCtm0GKfk-UhtXoyJKarnRV8" - } - - return key -} - -type recaptchaResponse struct { - Success bool `json:"success"` -} - -var s = securecookie.New([]byte(config.HashKey), nil).MaxAge(int((1 * time.Hour).Seconds())) - -func IsHuman(req *http.Request, rw http.ResponseWriter) bool { - if os.Getenv("GOOGLE_RECAPTCHA_DISABLED") != "" { - return true - } - - if cookie, _ := req.Cookie("session_id"); cookie != nil { - var value string - if err := s.Decode("session_id", cookie.Value, &value); err != nil { - fmt.Println(err) - return false - } - return true - } - - challenge := req.Form.Get("g-recaptcha-response") - - // Of X-Forwarded-For exists, it means we are behind a loadbalancer and we should use the real IP address of the user - ip := req.Header.Get("X-Forwarded-For") - if ip == "" { - // Use the standard remote IP address of the request - - ip = req.RemoteAddr - } - - parts := strings.Split(ip, ":") - - resp, postErr := http.PostForm("https://www.google.com/recaptcha/api/siteverify", url.Values{"secret": {GetGoogleRecaptchaSiteSecret()}, "response": {challenge}, "remoteip": {parts[0]}}) - if postErr != nil { - log.Println(postErr) - // If there is a problem to connect to google, assume the user is a human so we don't block real users because of technical issues - return true - } - - var r recaptchaResponse - json.NewDecoder(resp.Body).Decode(&r) - - if !r.Success { - return false - } - - encoded, _ := s.Encode("session_id", xid.New().String()) - http.SetCookie(rw, &http.Cookie{ - Name: "session_id", - Value: encoded, - Expires: time.Now().Add(1 * time.Hour), - }) - - return true -} diff --git a/storage/file.go b/storage/file.go index 28aa5fb..72d651c 100644 --- a/storage/file.go +++ b/storage/file.go @@ -2,6 +2,7 @@ package storage import ( "encoding/json" + "fmt" "os" "sync" @@ -19,10 +20,13 @@ type DB struct { Instances map[string]*types.Instance `json:"instances"` Clients map[string]*types.Client `json:"clients"` WindowsInstances map[string]*types.WindowsInstance `json:"windows_instances"` + LoginRequests map[string]*types.LoginRequest `json:"login_requests"` + Users map[string]*types.User `json:"user"` WindowsInstancesBySessionId map[string][]string `json:"windows_instances_by_session_id"` InstancesBySessionId map[string][]string `json:"instances_by_session_id"` ClientsBySessionId map[string][]string `json:"clients_by_session_id"` + UsersByProvider map[string]string `json:"users_by_providers"` } func (store *storage) SessionGet(id string) (*types.Session, error) { @@ -300,6 +304,56 @@ func (store *storage) ClientFindBySessionId(sessionId string) ([]*types.Client, return clients, nil } +func (store *storage) LoginRequestPut(loginRequest *types.LoginRequest) error { + store.rw.Lock() + defer store.rw.Unlock() + + store.db.LoginRequests[loginRequest.Id] = loginRequest + return nil +} +func (store *storage) LoginRequestGet(id string) (*types.LoginRequest, error) { + store.rw.Lock() + defer store.rw.Unlock() + + if lr, found := store.db.LoginRequests[id]; !found { + return nil, NotFoundError + } else { + return lr, nil + } +} +func (store *storage) LoginRequestDelete(id string) error { + store.rw.Lock() + defer store.rw.Unlock() + + delete(store.db.LoginRequests, id) + return nil +} + +func (store *storage) UserFindByProvider(providerName, providerUserId string) (*types.User, error) { + store.rw.Lock() + defer store.rw.Unlock() + + if userId, found := store.db.UsersByProvider[fmt.Sprintf("%s_%s", providerName, providerUserId)]; !found { + return nil, NotFoundError + } else { + if user, found := store.db.Users[userId]; !found { + return nil, NotFoundError + } else { + return user, nil + } + } +} + +func (store *storage) UserPut(user *types.User) error { + store.rw.Lock() + defer store.rw.Unlock() + + store.db.UsersByProvider[fmt.Sprintf("%s_%s", user.Provider, user.ProviderUserId)] = user.Id + store.db.Users[user.Id] = user + + return nil +} + func (store *storage) load() error { file, err := os.Open(store.path) @@ -312,13 +366,16 @@ func (store *storage) load() error { } } else { store.db = &DB{ - Sessions: map[string]*types.Session{}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } } diff --git a/storage/file_test.go b/storage/file_test.go index edb6b99..6bcae2a 100644 --- a/storage/file_test.go +++ b/storage/file_test.go @@ -30,13 +30,16 @@ func TestSessionPut(t *testing.T) { assert.Nil(t, err) expectedDB := &DB{ - Sessions: map[string]*types.Session{s.Id: s}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{s.Id: s}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } var loadedDB *DB @@ -56,13 +59,16 @@ func TestSessionPut(t *testing.T) { func TestSessionGet(t *testing.T) { expectedSession := &types.Session{Id: "aaabbbccc"} expectedDB := &DB{ - Sessions: map[string]*types.Session{expectedSession.Id: expectedSession}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{expectedSession.Id: expectedSession}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } tmpfile, err := ioutil.TempFile("", "pwd") @@ -92,13 +98,16 @@ func TestSessionGetAll(t *testing.T) { s1 := &types.Session{Id: "aaabbbccc"} s2 := &types.Session{Id: "dddeeefff"} expectedDB := &DB{ - Sessions: map[string]*types.Session{s1.Id: s1, s2.Id: s2}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{s1.Id: s1, s2.Id: s2}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } tmpfile, err := ioutil.TempFile("", "pwd") @@ -154,13 +163,16 @@ func TestSessionDelete(t *testing.T) { func TestInstanceGet(t *testing.T) { expectedInstance := &types.Instance{SessionId: "aaabbbccc", Name: "i1", IP: "10.0.0.1"} expectedDB := &DB{ - Sessions: map[string]*types.Session{}, - Instances: map[string]*types.Instance{expectedInstance.Name: expectedInstance}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{}, + Instances: map[string]*types.Instance{expectedInstance.Name: expectedInstance}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{expectedInstance.SessionId: []string{expectedInstance.Name}}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } tmpfile, err := ioutil.TempFile("", "pwd") @@ -205,13 +217,16 @@ func TestInstancePut(t *testing.T) { assert.Nil(t, err) expectedDB := &DB{ - Sessions: map[string]*types.Session{s.Id: s}, - Instances: map[string]*types.Instance{i.Name: i}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{s.Id: s}, + Instances: map[string]*types.Instance{i.Name: i}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{i.SessionId: []string{i.Name}}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } var loadedDB *DB @@ -265,13 +280,16 @@ func TestInstanceFindBySessionId(t *testing.T) { i1 := &types.Instance{SessionId: "aaabbbccc", Name: "c1"} i2 := &types.Instance{SessionId: "aaabbbccc", Name: "c2"} expectedDB := &DB{ - Sessions: map[string]*types.Session{}, - Instances: map[string]*types.Instance{i1.Name: i1, i2.Name: i2}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{}, + Instances: map[string]*types.Instance{i1.Name: i1, i2.Name: i2}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{i1.SessionId: []string{i1.Name, i2.Name}}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } tmpfile, err := ioutil.TempFile("", "pwd") @@ -298,13 +316,16 @@ func TestWindowsInstanceGetAll(t *testing.T) { i1 := &types.WindowsInstance{SessionId: "aaabbbccc", Id: "i1"} i2 := &types.WindowsInstance{SessionId: "aaabbbccc", Id: "i2"} expectedDB := &DB{ - Sessions: map[string]*types.Session{}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{i1.Id: i1, i2.Id: i2}, + Sessions: map[string]*types.Session{}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{i1.Id: i1, i2.Id: i2}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{i1.SessionId: []string{i1.Id, i2.Id}}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } tmpfile, err := ioutil.TempFile("", "pwd") @@ -350,13 +371,16 @@ func TestWindowsInstancePut(t *testing.T) { assert.Nil(t, err) expectedDB := &DB{ - Sessions: map[string]*types.Session{s.Id: s}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{}, - WindowsInstances: map[string]*types.WindowsInstance{i.Id: i}, + Sessions: map[string]*types.Session{s.Id: s}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{}, + WindowsInstances: map[string]*types.WindowsInstance{i.Id: i}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{i.SessionId: []string{i.Id}}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{}, + UsersByProvider: map[string]string{}, } var loadedDB *DB @@ -409,13 +433,16 @@ func TestWindowsInstanceDelete(t *testing.T) { func TestClientGet(t *testing.T) { c := &types.Client{SessionId: "aaabbbccc", Id: "c1"} expectedDB := &DB{ - Sessions: map[string]*types.Session{}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{c.Id: c}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{c.Id: c}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{c.SessionId: []string{c.Id}}, + UsersByProvider: map[string]string{}, } tmpfile, err := ioutil.TempFile("", "pwd") @@ -460,13 +487,16 @@ func TestClientPut(t *testing.T) { assert.Nil(t, err) expectedDB := &DB{ - Sessions: map[string]*types.Session{s.Id: s}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{c.Id: c}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{s.Id: s}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{c.Id: c}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{c.SessionId: []string{c.Id}}, + UsersByProvider: map[string]string{}, } var loadedDB *DB @@ -520,13 +550,16 @@ func TestClientFindBySessionId(t *testing.T) { c1 := &types.Client{SessionId: "aaabbbccc", Id: "c1"} c2 := &types.Client{SessionId: "aaabbbccc", Id: "c2"} expectedDB := &DB{ - Sessions: map[string]*types.Session{}, - Instances: map[string]*types.Instance{}, - Clients: map[string]*types.Client{c1.Id: c1, c2.Id: c2}, - WindowsInstances: map[string]*types.WindowsInstance{}, + Sessions: map[string]*types.Session{}, + Instances: map[string]*types.Instance{}, + Clients: map[string]*types.Client{c1.Id: c1, c2.Id: c2}, + WindowsInstances: map[string]*types.WindowsInstance{}, + LoginRequests: map[string]*types.LoginRequest{}, + Users: map[string]*types.User{}, WindowsInstancesBySessionId: map[string][]string{}, InstancesBySessionId: map[string][]string{}, ClientsBySessionId: map[string][]string{c1.SessionId: []string{c1.Id, c2.Id}}, + UsersByProvider: map[string]string{}, } tmpfile, err := ioutil.TempFile("", "pwd") diff --git a/storage/mock.go b/storage/mock.go index 490ec05..3d18278 100644 --- a/storage/mock.go +++ b/storage/mock.go @@ -82,3 +82,23 @@ func (m *Mock) ClientFindBySessionId(sessionId string) ([]*types.Client, error) args := m.Called(sessionId) return args.Get(0).([]*types.Client), args.Error(1) } +func (m *Mock) LoginRequestPut(loginRequest *types.LoginRequest) error { + args := m.Called(loginRequest) + return args.Error(0) +} +func (m *Mock) LoginRequestGet(id string) (*types.LoginRequest, error) { + args := m.Called(id) + return args.Get(0).(*types.LoginRequest), args.Error(1) +} +func (m *Mock) LoginRequestDelete(id string) error { + args := m.Called(id) + return args.Error(0) +} +func (m *Mock) UserFindByProvider(providerName, providerUserId string) (*types.User, error) { + args := m.Called(providerName, providerUserId) + return args.Get(0).(*types.User), args.Error(1) +} +func (m *Mock) UserPut(user *types.User) error { + args := m.Called(user) + return args.Error(0) +} diff --git a/storage/storage.go b/storage/storage.go index 62fa584..d199ef6 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -34,4 +34,11 @@ type StorageApi interface { ClientDelete(id string) error ClientCount() (int, error) ClientFindBySessionId(sessionId string) ([]*types.Client, error) + + LoginRequestPut(loginRequest *types.LoginRequest) error + LoginRequestGet(id string) (*types.LoginRequest, error) + LoginRequestDelete(id string) error + + UserFindByProvider(providerName, providerUserId string) (*types.User, error) + UserPut(user *types.User) error } diff --git a/templates/welcome.go b/templates/welcome.go deleted file mode 100644 index 92f3d30..0000000 --- a/templates/welcome.go +++ /dev/null @@ -1,21 +0,0 @@ -package templates - -import ( - "bytes" - "html/template" - - "github.com/play-with-docker/play-with-docker/recaptcha" -) - -func GetWelcomeTemplate() ([]byte, error) { - welcomeTemplate, tplErr := template.New("welcome").ParseFiles("www/welcome.html") - if tplErr != nil { - return nil, tplErr - } - var b bytes.Buffer - tplExecuteErr := welcomeTemplate.ExecuteTemplate(&b, "GOOGLE_RECAPTCHA_SITE_KEY", recaptcha.GetGoogleRecaptchaSiteKey()) - if tplExecuteErr != nil { - return nil, tplExecuteErr - } - return b.Bytes(), nil -} diff --git a/www/assets/app.js b/www/assets/app.js index 558f67d..ac8b581 100644 --- a/www/assets/app.js +++ b/www/assets/app.js @@ -78,7 +78,7 @@ }); - $scope.showAlert = function(title, content, parent) { + $scope.showAlert = function(title, content, parent, cb) { $mdDialog.show( $mdDialog.alert() .parent(angular.element(document.querySelector(parent || '#popupContainer'))) @@ -86,7 +86,11 @@ .title(title) .textContent(content) .ok('Got it!') - ); + ).finally(function() { + if (cb) { + cb(); + } + }); } $scope.resize = function(geometry) { @@ -206,7 +210,9 @@ }); socket.on('session end', function() { - $scope.showAlert('Session timed out!', 'Your session has expired and all of your instances have been deleted.', '#sessionEnd') + $scope.showAlert('Session timed out!', 'Your session has expired and all of your instances have been deleted.', '#sessionEnd', function() { + window.location.href = '/'; + }); $scope.isAlive = false; }); diff --git a/www/assets/landing.css b/www/assets/landing.css new file mode 100644 index 0000000..28cf773 --- /dev/null +++ b/www/assets/landing.css @@ -0,0 +1,79 @@ +/* Space out content a bit */ +body { + padding-top: 1.5rem; + padding-bottom: 1.5rem; +} + +/* Everything but the jumbotron gets side spacing for mobile first views */ +.header, +.marketing, +.footer { + padding-right: 1rem; + padding-left: 1rem; +} + +/* Custom page header */ +.header { + padding-bottom: 1rem; + border-bottom: .05rem solid #e5e5e5; +} +/* Make the masthead heading the same height as the navigation */ +.header h3 { + margin-top: 0; + margin-bottom: 0; + line-height: 3rem; +} + +/* Custom page footer */ +.footer { + padding-top: 1.5rem; + color: #777; + border-top: .05rem solid #e5e5e5; +} + +/* Customize container */ +@media (min-width: 48em) { + .container { + max-width: 46rem; + } +} +.container-narrow > hr { + margin: 2rem 0; +} + +/* Main marketing message and sign up button */ +.jumbotron { + text-align: center; + border-bottom: .05rem solid #e5e5e5; +} +.jumbotron .btn { + padding: .75rem 1.5rem; + font-size: 1.5rem; +} + +/* Supporting marketing content */ +.marketing { + margin: 3rem 0; +} +.marketing p + h4 { + margin-top: 1.5rem; +} + +/* Responsive: Portrait tablets and up */ +@media screen and (min-width: 48em) { + /* Remove the padding we set earlier */ + .header, + .marketing, + .footer { + padding-right: 0; + padding-left: 0; + } + /* Space out the masthead */ + .header { + margin-bottom: 2rem; + } + /* Remove the bottom border on the jumbotron for visual effect */ + .jumbotron { + border-bottom: 0; + } +} diff --git a/www/landing.html b/www/landing.html new file mode 100644 index 0000000..18ac29a --- /dev/null +++ b/www/landing.html @@ -0,0 +1,116 @@ + + + +
+ + + + + + + +
+ A simple, interactive and fun playground to learn Docker
+Play with Docker (PWD) is a project hacked by Marcos Liljedhal and Jonathan Leibiusky and sponsored by Docker Inc.
+PWD is a Docker playground which allows users to run Docker commands in a matter of seconds. It gives the experience of having a free Alpine Linux Virtual Machine in browser, where you can build and run Docker containers and even create clusters in Docker Swarm Mode. Under the hood Docker-in-Docker (DinD) is used to give the effect of multiple VMs/PCs. In addition to the playground, PWD also includes a training site composed of a large set of Docker labs and quizzes from beginner to advanced level available at training.play-with-docker.com.
+