doortts doortts 2017-02-01
authentication: Support Github/Gmail social login
Yona support social login, Github and Gmail.
It doesn't need to sign up anymore.

This feature was easily developed thanks to "Play Athenticate"
See: https://github.com/joscha/play-authenticate
@2bb95f73379676f5921f8aaee340cd96a40d139d
.gitignore
--- .gitignore
+++ .gitignore
@@ -31,3 +31,4 @@
 conf/application-logger.xml
 .java-version
 migration-client
+conf/play-authenticate/mine.conf
app/Global.java
--- app/Global.java
+++ app/Global.java
@@ -20,6 +20,9 @@
  */
 
 import com.avaje.ebean.Ebean;
+import com.feth.play.module.pa.PlayAuthenticate;
+import com.feth.play.module.pa.exceptions.AccessDeniedException;
+import com.feth.play.module.pa.exceptions.AuthException;
 import com.typesafe.config.ConfigFactory;
 import controllers.SvnApp;
 import controllers.UserApp;
@@ -34,11 +37,8 @@
 import play.api.mvc.Handler;
 import play.data.Form;
 import play.libs.F.Promise;
-import play.mvc.Action;
-import play.mvc.Http;
+import play.mvc.*;
 import play.mvc.Http.RequestHeader;
-import play.mvc.Result;
-import play.mvc.Results;
 import utils.*;
 import views.html.welcome.restart;
 import views.html.welcome.secret;
@@ -63,6 +63,7 @@
 
 import static play.data.Form.form;
 import static play.mvc.Results.badRequest;
+import static play.mvc.Results.redirect;
 
 
 public class Global extends GlobalSettings {
@@ -76,10 +77,12 @@
 
     private ConfigFile configFile = new ConfigFile("config", "application.conf");
     private ConfigFile loggerConfigFile = new ConfigFile("logger", "application-logger.xml");
+    private ConfigFile oAuthProviderConfFile = new ConfigFile("conf", "social-login.conf");
 
     @Override
     public Configuration onLoadConfig(play.Configuration config, File path, ClassLoader classloader) {
         initLoggerConfig();
+        initAuthProviderConfig();
         return initConfig(classloader);
     }
 
@@ -133,6 +136,23 @@
         }
     }
 
+    /**
+     * Creates play-authenticate/mine.conf by default if necessary
+     */
+    private void initAuthProviderConfig() {
+        try {
+            if (!oAuthProviderConfFile.isLocationSpecified() && !oAuthProviderConfFile.getPath().toFile().exists()) {
+                try {
+                    oAuthProviderConfFile.createByDefault();
+                } catch (Exception e) {
+                    play.Logger.error("Failed to initialize social-login.conf", e);
+                }
+            }
+        } catch (URISyntaxException e) {
+            play.Logger.error("Failed to check whether the social-login.conf file exists", e);
+        }
+    }
+
     @Override
     public void onStart(Application app) {
         isSecretInvalid = equalsDefaultSecret();
@@ -150,6 +170,59 @@
             YobiUpdate.onStart();
             mailboxService.start();
         }
+
+        PlayAuthenticate.setResolver(new PlayAuthenticate.Resolver() {
+
+            @Override
+            public Call login() {
+                // Your login page
+                return routes.Application.index();
+            }
+
+            @Override
+            public Call afterAuth() {
+                // The user will be redirected to this page after authentication
+                // if no original URL was saved
+                return routes.Application.index();
+            }
+
+            @Override
+            public Call afterLogout() {
+                return routes.Application.index();
+            }
+
+            @Override
+            public Call auth(final String provider) {
+                return routes.Application.oAuth(provider);
+            }
+
+            @Override
+            public Call onException(final AuthException e) {
+                if (e instanceof AccessDeniedException) {
+                    return routes.Application
+                            .oAuthDenied(((AccessDeniedException) e)
+                                    .getProviderKey());
+                }
+
+                // more custom problem handling here...
+
+                return super.onException(e);
+            }
+
+            @Override
+            public Call askLink() {
+                // We don't support moderated account linking in this sample.
+                // See the play-authenticate-usage project for an example
+                return null;
+            }
+
+            @Override
+            public Call askMerge() {
+                // We don't support moderated account merging in this sample.
+                // See the play-authenticate-usage project for an example
+                return null;
+            }
+        });
     }
 
     private boolean equalsDefaultSecret() {
app/assets/stylesheets/less/_override.less
--- app/assets/stylesheets/less/_override.less
+++ app/assets/stylesheets/less/_override.less
@@ -365,3 +365,7 @@
     margin-bottom: -1px;
 }
 
+.oauth-login-btn{
+    display: block;
+    margin: 10px 0;
+}
app/assets/stylesheets/less/_page.less
--- app/assets/stylesheets/less/_page.less
+++ app/assets/stylesheets/less/_page.less
@@ -6638,3 +6638,17 @@
     margin-left: 10px;
     width: 400px;
 }
+
+.auth-provider-logo {
+    font-family: 'Roboto', sans-serif;
+    svg {
+        vertical-align: middle;
+    }
+    .github {
+        width: 30px;
+        display: inline-block;
+        margin-left: -4px;
+        margin-top: 3px;
+        margin-bottom: 3px;
+    }
+}
app/controllers/Application.java
--- app/controllers/Application.java
+++ app/controllers/Application.java
@@ -1,44 +1,63 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2012 NAVER Corp.
- * http://yobi.io
- *
- * @author Sangcheol Hwang
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
 package controllers;
 
+import com.feth.play.module.pa.PlayAuthenticate;
 import controllers.annotation.AnonymousCheck;
+import jsmessages.JsMessages;
 import models.Project;
+import models.UserCredential;
 import play.Logger;
 import play.mvc.Controller;
+import play.mvc.Http;
 import play.mvc.Result;
 import playRepository.RepositoryService;
 import views.html.error.notfound_default;
 import views.html.index.index;
-import jsmessages.JsMessages;
 
-import java.io.File;
+import static com.feth.play.module.pa.controllers.Authenticate.*;
 
 public class Application extends Controller {
+    public static final String FLASH_MESSAGE_KEY = "message";
+    public static final String FLASH_ERROR_KEY = "error";
 
     @AnonymousCheck
     public static Result index() {
         return ok(index.render(UserApp.currentUser()));
     }
 
+    public static Result oAuth(final String provider) {
+        return authenticate(provider);
+    }
+
+    public static Result oAuthLogout() {
+        UserApp.logout();
+        return logout();
+    }
+
+    public static Result oAuthDenied(final String providerKey) {
+        noCache(response());
+        flash(FLASH_ERROR_KEY,
+                "You need to accept the OAuth connection in order to use this website!");
+        return redirect(routes.Application.index());
+    }
+
+    public static UserCredential getLocalUser(final Http.Session session) {
+        final UserCredential localUser = UserCredential.findByAuthUserIdentity(PlayAuthenticate
+                .getUser(session));
+        return localUser;
+    }
+
+    public static UserCredential getLocalUser() {
+        final UserCredential localUser = UserCredential.findByAuthUserIdentity(PlayAuthenticate
+                .getUser(session()));
+        return localUser;
+    }
+
     public static Result removeTrailer(String paths){
         String path = request().path();
         if( path.charAt(path.length()-1) == '/' ) {
 
app/controllers/Restricted.java (added)
+++ app/controllers/Restricted.java
@@ -0,0 +1,16 @@
+package controllers;
+
+import models.UserCredential;
+import play.mvc.Controller;
+import play.mvc.Result;
+import play.mvc.Security;
+import views.html.restricted;
+
+@Security.Authenticated(Secured.class)
+public class Restricted extends Controller {
+
+	public static Result index() {
+		final UserCredential localUser = Application.getLocalUser(session());
+		return ok(restricted.render(localUser));
+	}
+}
 
app/controllers/Secured.java (added)
+++ app/controllers/Secured.java
@@ -0,0 +1,27 @@
+package controllers;
+
+import com.feth.play.module.pa.PlayAuthenticate;
+import com.feth.play.module.pa.user.AuthUser;
+import play.mvc.Http.Context;
+import play.mvc.Result;
+import play.mvc.Security;
+
+public class Secured extends Security.Authenticator {
+
+	@Override
+	public String getUsername(final Context ctx) {
+		final AuthUser u = PlayAuthenticate.getUser(ctx.session());
+
+		if (u != null) {
+			return u.getId();
+		} else {
+			return null;
+		}
+	}
+
+	@Override
+	public Result onUnauthorized(final Context ctx) {
+		ctx.flash().put(Application.FLASH_MESSAGE_KEY, "Nice try, but you need to log in first!");
+		return redirect(routes.Application.index());
+	}
+}(파일 끝에 줄바꿈 문자 없음)
app/controllers/UserApp.java
--- app/controllers/UserApp.java
+++ app/controllers/UserApp.java
@@ -1,28 +1,16 @@
 /**
- * Yobi, Project Hosting SW
- *
- * Copyright 2013 NAVER Corp.
- * http://yobi.io
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
 package controllers;
 
 import com.avaje.ebean.ExpressionList;
 import com.avaje.ebean.Page;
 import com.avaje.ebean.annotation.Transactional;
 import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.feth.play.module.pa.PlayAuthenticate;
 import controllers.annotation.AnonymousCheck;
 import models.*;
 import models.enumeration.Operation;
@@ -121,7 +109,14 @@
         if(StringUtils.isEmpty(redirectUrl) && !StringUtils.equals(loginFormUrl, referer)) {
             redirectUrl = request().getHeader("Referer");
         }
-        return ok(login.render("title.login", form(AuthInfo.class), redirectUrl));
+
+        //Assume oAtuh is passed but not linked with existed account
+        if(PlayAuthenticate.isLoggedIn(session())){
+            UserApp.linkWithExistedOrCreateLocalUser();
+            return redirect(redirectUrl);
+        } else {
+            return ok(login.render("title.login", form(AuthInfo.class), redirectUrl));
+        }
     }
 
     public static Result logout() {
@@ -329,6 +324,56 @@
                 addUserInfoToSession(user);
             }
             return redirect(routes.Application.index());
+        }
+    }
+
+    private static String newLoginIdWithoutDup(final String candidate, int num) {
+        String newLoginIdSuggestion = candidate + "" + num;
+        if(User.findByLoginId(newLoginIdSuggestion).isAnonymous()){
+            return newLoginIdSuggestion;
+        } else {
+            num = num + 1;
+            return newLoginIdWithoutDup(newLoginIdSuggestion, num);
+        }
+    }
+
+    public static void createLocalUserWithOAuth(UserCredential userCredential){
+        User user = new User();
+        String loginIdCandidate = userCredential.email.substring(0, userCredential.email.indexOf("@"));
+
+        user.loginId = generateLoginId(user, loginIdCandidate);
+        user.name = userCredential.name;
+        user.email = userCredential.email;
+
+        RandomNumberGenerator rng = new SecureRandomNumberGenerator();
+        user.password = rng.nextBytes().toBase64();  // random password because created with oAuth
+
+        User created = createNewUser(user);
+
+        if (created.state == UserState.LOCKED) {
+            flash(Constants.INFO, "user.signup.requested");
+        } else {
+            addUserInfoToSession(created);
+        }
+
+        //Also, update userCredential
+        userCredential.loginId = created.loginId;
+        userCredential.user = created;
+        userCredential.update();
+    }
+
+    private static String generateLoginId(User user, String loginIdCandidate) {
+        String loginId = null;
+        User sameLoginIdUser = User.findByLoginId(loginIdCandidate);
+        if (sameLoginIdUser.isAnonymous()) {
+            return loginIdCandidate;
+        } else {
+            sameLoginIdUser = User.findByLoginId(loginIdCandidate + "-yona");
+            if (sameLoginIdUser.isAnonymous()) {
+                return loginIdCandidate + "-yona";   // first dup, then use suffix "-yona"
+            } else {
+                return newLoginIdWithoutDup(loginIdCandidate, 2);
+            }
         }
     }
 
@@ -936,6 +981,28 @@
         session(SESSION_KEY, key);
     }
 
+    public static void linkWithExistedOrCreateLocalUser() {
+        final UserCredential oAuthUser = UserCredential.findByAuthUserIdentity(PlayAuthenticate
+                .getUser(Http.Context.current().session()));
+        User user = null;
+        if (oAuthUser.loginId == null) {
+            user = User.findByEmail(oAuthUser.email);
+        } else {
+            user = User.findByLoginId(oAuthUser.loginId);
+        }
+
+        if(PlayAuthenticate.isLoggedIn(session()) && user.isAnonymous()){
+            createLocalUserWithOAuth(oAuthUser);
+        } else {
+            if (oAuthUser.loginId == null) {
+                oAuthUser.loginId = user.loginId;
+                oAuthUser.user = user;
+                oAuthUser.update();
+            }
+            UserApp.addUserInfoToSession(user);
+        }
+    }
+
     public static void updatePreferredLanguage() {
         Http.Request request = Http.Context.current().request();
         User user = UserApp.currentUser();
 
app/models/LinkedAccount.java (added)
+++ app/models/LinkedAccount.java
@@ -0,0 +1,50 @@
+package models;
+
+import com.feth.play.module.pa.user.AuthUser;
+import play.db.ebean.Model;
+
+import javax.persistence.Entity;
+import javax.persistence.Id;
+import javax.persistence.ManyToOne;
+
+@Entity
+public class LinkedAccount extends Model {
+
+	private static final long serialVersionUID = 1L;
+
+	@Id
+	public Long id;
+
+	@ManyToOne
+	public UserCredential userCredential;
+
+	public String providerUserId;
+	public String providerKey;
+
+	public static final Finder<Long, LinkedAccount> find = new Finder<Long, LinkedAccount>(
+			Long.class, LinkedAccount.class);
+
+	public static LinkedAccount findByProviderKey(final UserCredential userCredential, String key) {
+		return find.where().eq("userCredential", userCredential).eq("providerKey", key)
+				.findUnique();
+	}
+
+	public static LinkedAccount create(final AuthUser authUser) {
+		final LinkedAccount ret = new LinkedAccount();
+		ret.update(authUser);
+		return ret;
+	}
+	
+	public void update(final AuthUser authUser) {
+		this.providerKey = authUser.getProvider();
+		this.providerUserId = authUser.getId();
+	}
+
+	public static LinkedAccount create(final LinkedAccount acc) {
+		final LinkedAccount ret = new LinkedAccount();
+		ret.providerKey = acc.providerKey;
+		ret.providerUserId = acc.providerUserId;
+
+		return ret;
+	}
+}(파일 끝에 줄바꿈 문자 없음)
 
app/models/UserCredential.java (added)
+++ app/models/UserCredential.java
@@ -0,0 +1,142 @@
+/**
+ * Yona, 21st Century Project Hosting SW
+ * <p>
+ * Copyright Yona & Yobi Authors & NAVER Corp.
+ * https://yona.io
+ **/
+package models;
+
+import com.avaje.ebean.Ebean;
+import com.avaje.ebean.ExpressionList;
+import com.feth.play.module.pa.user.AuthUser;
+import com.feth.play.module.pa.user.AuthUserIdentity;
+import com.feth.play.module.pa.user.EmailIdentity;
+import com.feth.play.module.pa.user.NameIdentity;
+import play.data.validation.Constraints;
+import play.db.ebean.Model;
+
+import javax.persistence.*;
+import java.util.*;
+
+@Entity
+public class UserCredential extends Model {
+    private static final long serialVersionUID = 1L;
+
+    @Id
+    public Long id;
+
+    @OneToOne
+    public User user;
+
+    public String loginId;
+
+    @Constraints.Email
+    // if you make this unique, keep in mind that users *must* merge/link their
+    // accounts then on signup with additional providers
+    // @Column(unique = true)
+    public String email;
+
+    public String name;
+
+    public boolean active;
+
+    public boolean emailValidated;
+
+    @OneToMany(cascade = CascadeType.ALL)
+    public List<LinkedAccount> linkedAccounts;
+
+    public static final Finder<Long, UserCredential> find = new Finder<Long, UserCredential>(
+            Long.class, UserCredential.class);
+
+    public static boolean existsByAuthUserIdentity(
+            final AuthUserIdentity identity) {
+        final ExpressionList<UserCredential> exp = getAuthUserFind(identity);
+        return exp.findRowCount() > 0;
+    }
+
+    private static ExpressionList<UserCredential> getAuthUserFind(
+            final AuthUserIdentity identity) {
+        return find.where().eq("active", true)
+                .eq("linkedAccounts.providerUserId", identity.getId())
+                .eq("linkedAccounts.providerKey", identity.getProvider());
+    }
+
+    public static UserCredential findByAuthUserIdentity(final AuthUserIdentity identity) {
+        if (identity == null) {
+            return null;
+        }
+        return getAuthUserFind(identity).findUnique();
+    }
+
+    public void merge(final UserCredential otherUser) {
+        for (final LinkedAccount acc : otherUser.linkedAccounts) {
+            this.linkedAccounts.add(LinkedAccount.create(acc));
+        }
+        // do all other merging stuff here - like resources, etc.
+
+        // deactivate the merged user that got added to this one
+        otherUser.active = false;
+        Ebean.save(Arrays.asList(new UserCredential[] { otherUser, this }));
+    }
+
+    public static UserCredential create(final AuthUser authUser) {
+        final UserCredential userCredential = new UserCredential();
+        userCredential.active = true;
+        userCredential.linkedAccounts = Collections.singletonList(LinkedAccount
+                .create(authUser));
+
+        if (authUser instanceof EmailIdentity) {
+            final EmailIdentity identity = (EmailIdentity) authUser;
+            // Remember, even when getting them from FB & Co., emails should be
+            // verified within the application as a security breach there might
+            // break your security as well!
+            userCredential.email = identity.getEmail();
+            userCredential.emailValidated = false;
+        }
+
+        if (authUser instanceof NameIdentity) {
+            final NameIdentity identity = (NameIdentity) authUser;
+            final String name = identity.getName();
+            if (name != null) {
+                userCredential.name = name;
+            }
+        }
+
+        userCredential.save();
+        return userCredential;
+    }
+
+    public static void merge(final AuthUser oldUser, final AuthUser newUser) {
+        UserCredential.findByAuthUserIdentity(oldUser).merge(
+                UserCredential.findByAuthUserIdentity(newUser));
+    }
+
+    public Set<String> getProviders() {
+        final Set<String> providerKeys = new HashSet<String>(
+                linkedAccounts.size());
+        for (final LinkedAccount acc : linkedAccounts) {
+            providerKeys.add(acc.providerKey);
+        }
+        return providerKeys;
+    }
+
+    public static void addLinkedAccount(final AuthUser oldUser,
+                                        final AuthUser newUser) {
+        final UserCredential u = UserCredential.findByAuthUserIdentity(oldUser);
+        u.linkedAccounts.add(LinkedAccount.create(newUser));
+        u.save();
+    }
+
+    public static UserCredential findByEmail(final String email) {
+        return getEmailUserFind(email).findUnique();
+    }
+
+    private static ExpressionList<UserCredential> getEmailUserFind(final String email) {
+        return find.where().eq("active", true).eq("email", email);
+    }
+
+    public LinkedAccount getAccountByProvider(final String providerKey) {
+        return LinkedAccount.findByProviderKey(this, providerKey);
+    }
+
+}
 
app/service/YonaUserServicePlugin.java (added)
+++ app/service/YonaUserServicePlugin.java
@@ -0,0 +1,52 @@
+package service;
+
+import com.feth.play.module.pa.service.UserServicePlugin;
+import com.feth.play.module.pa.user.AuthUser;
+import com.feth.play.module.pa.user.AuthUserIdentity;
+import models.UserCredential;
+import play.Application;
+
+public class YonaUserServicePlugin extends UserServicePlugin {
+
+	public YonaUserServicePlugin(final Application app) {
+		super(app);
+	}
+
+	@Override
+	public Object save(final AuthUser authUser) {
+		final boolean isLinked = UserCredential.existsByAuthUserIdentity(authUser);
+		if (!isLinked) {
+			return UserCredential.create(authUser).id;
+		} else {
+			// we have this user already, so return null
+			return null;
+		}
+	}
+
+	@Override
+	public Object getLocalIdentity(final AuthUserIdentity identity) {
+		// For production: Caching might be a good idea here...
+		// ...and dont forget to sync the cache when users get deactivated/deleted
+		final UserCredential u = UserCredential.findByAuthUserIdentity(identity);
+		if(u != null) {
+			return u.id;
+		} else {
+			return null;
+		}
+	}
+
+	@Override
+	public AuthUser merge(final AuthUser newUser, final AuthUser oldUser) {
+		if (!oldUser.equals(newUser)) {
+			UserCredential.merge(oldUser, newUser);
+		}
+		return oldUser;
+	}
+
+	@Override
+	public AuthUser link(final AuthUser oldUser, final AuthUser newUser) {
+		UserCredential.addLinkedAccount(oldUser, newUser);
+		return null;
+	}
+
+}
app/utils/TemplateHelper.scala
--- app/utils/TemplateHelper.scala
+++ app/utils/TemplateHelper.scala
@@ -1,7 +1,7 @@
 package utils
 
 import org.apache.commons.lang3.StringUtils
-import play.mvc.Call
+import play.mvc.{Call, Http}
 import org.joda.time.DateTimeConstants
 import org.apache.commons.io.FilenameUtils
 import play.i18n.Messages
@@ -13,7 +13,6 @@
 import playRepository.DiffLine
 import playRepository.DiffLineType
 import models.CodeRange.Side
-
 import views.html.partial_diff_comment_on_line
 import views.html.partial_diff_line
 import views.html.git.partial_pull_request_event
app/views/common/loginDialog.scala.html
--- app/views/common/loginDialog.scala.html
+++ app/views/common/loginDialog.scala.html
@@ -1,24 +1,22 @@
 @**
-* Yobi, Project Hosting SW
+* Yona, 21st Century Project Hosting SW
 *
-* Copyright 2014 NAVER Corp.
-* http://yobi.io
-*
-* @author Jihan Kim
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*   http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
 **@
 @()
+@import com.feth.play.module.pa.views.html._
+
+@providerWithLogo(provider:String) = @{
+  val googleLogo = routes.Assets.at("images/provider-logo/btn_google_light_normal_ios.svg")
+  val githubLogo = routes.Assets.at("images/provider-logo/github.svg")
+  provider match {
+    case "github" => s"""<span class="auth-provider-logo"><span class="github"><svg aria-hidden="true" height="24" version="1.1" viewBox="0 0 16 16" width="20"><path
+    d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59 0.4 0.07 0.55-0.17 0.55-0.38 0-0.19-0.01-0.82-0.01-1.49-2.01 0.37-2.53-0.49-2.69-0.94-0.09-0.23-0.48-0.94-0.82-1.13-0.28-0.15-0.68-0.52-0.01-0.53 0.63-0.01 1.08 0.58 1.23 0.82 0.72 1.21 1.87 0.87 2.33 0.66 0.07-0.52 0.28-0.87 0.51-1.07-1.78-0.2-3.64-0.89-3.64-3.95 0-0.87 0.31-1.59 0.82-2.15-0.08-0.2-0.36-1.02 0.08-2.12 0 0 0.67-0.21 2.2 0.82 0.64-0.18 1.32-0.27 2-0.27 0.68 0 1.36 0.09 2 0.27 1.53-1.04 2.2-0.82 2.2-0.82 0.44 1.1 0.16 1.92 0.08 2.12 0.51 0.56 0.82 1.27 0.82 2.15 0 3.07-1.87 3.75-3.65 3.95 0.29 0.25 0.54 0.73 0.54 1.48 0 1.07-0.01 1.93-0.01 2.2 0 0.21 0.15 0.46 0.55 0.38C13.71 14.53 16 11.53 16 8 16 3.58 12.42 0 8 0z"></path></svg></span> <span class="provider-name">Sign in with Github</span></span>"""
+    case "google" => s"""<span class="auth-provider-logo"><img src="$googleLogo" alt="login with Google"> Sign in with Google</span>"""
+    case _ => ""
+  }
+}
 
 <div id="loginDialog" class="modal hide loginDialog" tabindex="-1" role="dialog">
   <div class="modal-body">
@@ -51,6 +49,15 @@
         <button type="submit" class="ybtn ybtn-primary fullsize">@Messages("button.login")</button>
       </div>
 
+      <div class="btns-row nm">
+        @currentAuth() { auth =>
+          @if(auth == null) {
+            @forProviders() { p =>
+              <a href="@p.getUrl()" class="ybtn oauth-login-btn">@Html(providerWithLogo(p.getKey()))</a>
+            }
+          }
+        }
+      </div>
       <div class="act-row right-txt mt20">
         <div class="pull-left">
           <input id="remember-meD" type="checkbox" name="rememberMe" class="checkbox" checked>
app/views/common/usermenu.scala.html
--- app/views/common/usermenu.scala.html
+++ app/views/common/usermenu.scala.html
@@ -6,13 +6,32 @@
 **@
 @(project:Project)
 @import utils.TemplateHelper._
+@import com.feth.play.module.pa.PlayAuthenticate._
+@import com.feth.play.module.pa.views.html._
+@import play.mvc.Http._;
 @orderString = @{"createdDate DESC"}
+@if(UserApp.currentUser().isAnonymous){
+    @currentAuth() { auth => @{
+            if(auth == null) {
+                val url = storeOriginalUrl(Context.current())
+            } else {
+                UserApp.linkWithExistedOrCreateLocalUser()
+            }
+        }
+    }
+}
 <div id="mySidenav" class="sidenav">
     <div class="span5 right-menu span-hard-wrap">
         <div class="row-fluid user-menu-wrap">
             <span class="user-menu"><a href="@routes.UserApp.userInfo(UserApp.currentUser().loginId)">@Messages("userinfo.profile")</a></span>
             <span class="user-menu"><a href="@routes.UserApp.editUserInfoForm()">@Messages("userinfo.accountSetting")</a></span>
-            <a href="@routes.UserApp.logout()"><span class="user-menu logout label">@Messages("title.logout")</span></a>
+            @currentAuth() { auth =>
+                @if(auth != null) {
+                    <a href="@routes.Application.oAuthLogout"><span class="user-menu logout label">@Messages("title.logout")</span></a>
+                } else {
+                    <a href="@routes.UserApp.logout()"><span class="user-menu logout label">@Messages("title.logout")</span></a>
+                }
+            }
         </div>
         <ul class="nav nav-tabs nm">
             <li class="myProjectList active">
app/views/index/partial_intro.scala.html
--- app/views/index/partial_intro.scala.html
+++ app/views/index/partial_intro.scala.html
@@ -1,23 +1,11 @@
 @**
-* Yobi, Project Hosting SW
+* Yona, 21st Century Project Hosting SW
 *
-* Copyright 2013 NAVER Corp.
-* http://yobi.io
-*
-* @author Deokhong Kim
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*   http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
 **@
+@import com.feth.play.module.pa.views.html._
+
 <div class="siteintro-bg row">
 <div class="siteintro">
     <div class="siteintro-cover">
app/views/layout.scala.html
--- app/views/layout.scala.html
+++ app/views/layout.scala.html
@@ -22,7 +22,8 @@
 <meta name="twitter:title" content="@titleArray(0)" />
 <meta name="twitter:url" content="@play.mvc.Http.Context.current().request().path()" />
 <meta name="twitter:description" content="@{titleArray(titleArray.length-1)}" />
-<link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.ico")">
+<link rel="shortcut icon" type="image/x-icon" href="@routes.Assets.at("images/favicon.ico")">
+<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
 <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("bootstrap/css/bootstrap.css")">
 <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("stylesheets/yobicon/style.css")">
 <link rel="stylesheet" type="text/css" media="all" href="@routes.Assets.at("javascripts/lib/select2/select2.css")"/>
 
app/views/restricted.scala.html (added)
+++ app/views/restricted.scala.html
@@ -0,0 +1,31 @@
+@(localUser: models.UserCredential = null)
+
+@import com.feth.play.module.pa.views.html._
+
+@siteLayout(utils.Config.getSiteName, utils.MenuType.SITE_HOME) {
+    
+    <h1>Sshhh...don't tell anyone!</h1>
+    <p>
+        <iframe width="560" height="315" src="https://www.youtube.com/embed/9bZkp7q19f0" frameborder="0" allowfullscreen></iframe>
+    </p>
+    <p>
+        Your name is @localUser.name and your email address is @localUser.email
+    <i>
+    @if(!localUser.emailValidated) {
+      (unverified)
+    } else {
+      (verified)
+    }</i>!
+    
+    <br/>
+    @currentAuth() { auth =>
+        Logged in with provider '@auth.getProvider()' and the user ID '@auth.getId()'<br/>
+        Your session expires
+        @if(auth.expires() == -1){
+            never
+        } else {
+            at @auth.expires() (UNIX timestamp)
+        }
+    }
+    </p>
+}(파일 끝에 줄바꿈 문자 없음)
app/views/user/login.scala.html
--- app/views/user/login.scala.html
+++ app/views/user/login.scala.html
@@ -1,24 +1,25 @@
 @**
-* Yobi, Project Hosting SW
+* Yona, 21st Century Project Hosting SW
 *
-* Copyright 2012 NAVER Corp.
-* http://yobi.io
-*
-* @author Hwi Ahn
-*
-* Licensed under the Apache License, Version 2.0 (the "License");
-* you may not use this file except in compliance with the License.
-* You may obtain a copy of the License at
-*
-*   http://www.apache.org/licenses/LICENSE-2.0
-*
-* Unless required by applicable law or agreed to in writing, software
-* distributed under the License is distributed on an "AS IS" BASIS,
-* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-* See the License for the specific language governing permissions and
-* limitations under the License.
+* Copyright Yona & Yobi Authors & NAVER Corp.
+* https://yona.io
 **@
-@(message:String, authInfoForm: play.data.Form[AuthInfo], redirectUrl:String)
+@import play.data.Form
+@(message:String, authInfoForm: Form[AuthInfo], redirectUrl:String)
+@import com.feth.play.module.pa.views.html._
+@import com.feth.play.module.pa.PlayAuthenticate._;
+@import play.mvc.Http._;
+
+@providerWithLogo(provider:String) = @{
+  val googleLogo = routes.Assets.at("images/provider-logo/btn_google_light_normal_ios.svg")
+  val githubLogo = routes.Assets.at("images/provider-logo/github.svg")
+  provider match {
+    case "github" => s"""<span class="auth-provider-logo"><span class="github"><svg aria-hidden="true" height="24" version="1.1" viewBox="0 0 16 16" width="20"><path
+    d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59 0.4 0.07 0.55-0.17 0.55-0.38 0-0.19-0.01-0.82-0.01-1.49-2.01 0.37-2.53-0.49-2.69-0.94-0.09-0.23-0.48-0.94-0.82-1.13-0.28-0.15-0.68-0.52-0.01-0.53 0.63-0.01 1.08 0.58 1.23 0.82 0.72 1.21 1.87 0.87 2.33 0.66 0.07-0.52 0.28-0.87 0.51-1.07-1.78-0.2-3.64-0.89-3.64-3.95 0-0.87 0.31-1.59 0.82-2.15-0.08-0.2-0.36-1.02 0.08-2.12 0 0 0.67-0.21 2.2 0.82 0.64-0.18 1.32-0.27 2-0.27 0.68 0 1.36 0.09 2 0.27 1.53-1.04 2.2-0.82 2.2-0.82 0.44 1.1 0.16 1.92 0.08 2.12 0.51 0.56 0.82 1.27 0.82 2.15 0 3.07-1.87 3.75-3.65 3.95 0.29 0.25 0.54 0.73 0.54 1.48 0 1.07-0.01 1.93-0.01 2.2 0 0.21 0.15 0.46 0.55 0.38C13.71 14.53 16 11.53 16 8 16 3.58 12.42 0 8 0z"></path></svg></span> <span class="provider-name">Sign in with Github</span></span>"""
+    case "google" => s"""<span class="auth-provider-logo"><img src="$googleLogo" alt="login with Google"> Sign in with Google</span>"""
+    case _ => ""
+  }
+}
 
 @siteLayout(message, utils.MenuType.NONE) {
 <div class="page full">
@@ -51,6 +52,16 @@
         <button type="submit" class="ybtn ybtn-primary ybtn-large ybtn-fullsize">@Messages("button.login")</button>
       </div>
 
+      <div class="btns-row nm">
+      @currentAuth() { auth =>
+        @if(auth == null) {
+          @forProviders() { p =>
+            <a href="@p.getUrl()" class="ybtn oauth-login-btn">@Html(providerWithLogo(p.getKey()))</a>
+          }
+        }
+      }
+      </div>
+
       <div class="act-row mt5">
         <div class="remember-me-wrap pull-left">
           <input id="remember-me" type="checkbox" name="rememberMe" class="checkbox" checked>
build.sbt
--- build.sbt
+++ build.sbt
@@ -12,6 +12,9 @@
   javaEbean,
   javaWs,
   cache,
+  // PlayAuthenticat for social login
+  // https://github.com/joscha/play-authenticate
+  "com.feth" %% "play-authenticate" % "0.6.9",
   // OWASP Java HTML Sanitizer
   // https://www.owasp.org/index.php/OWASP_Java_HTML_Sanitizer_Project
   "com.googlecode.owasp-java-html-sanitizer" % "owasp-java-html-sanitizer" % "20160628.1",
conf/application.conf.default
--- conf/application.conf.default
+++ conf/application.conf.default
@@ -259,8 +259,17 @@
 github.client.id = "TYPE YOUR GITHUB CILENT ID"
 github.client.secret = "TYPE YOUR GITHUB CILENT SECRET"
 
-
 # Attachment Upload File Size Limit
 # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 # 2,147,483,454 bytes = 2Gb
 application.maxFileSize = 2147483454
+
+# Social Login Support
+# ~~~~~~~~~~~~~~~~~~~~
+# Social login settings for Yona
+# Detail settings are described at conf/play-authenticate/mine.conf
+
+# Prevent using Yona's own login system
+application.use.social.login.only = true
+
+include "social-login.conf"
 
conf/evolutions/default/12.sql (added)
+++ conf/evolutions/default/12.sql
@@ -0,0 +1,40 @@
+# --- !Ups
+
+create table linked_account (
+  id                        bigint auto_increment not null,
+  user_credential_id        bigint,
+  provider_user_id          varchar(255),
+  provider_key              varchar(255),
+  constraint pk_linked_account primary key (id))
+  row_format=compressed, key_block_size=8
+;
+
+create table user_credential (
+  id                        bigint auto_increment not null,
+  user_id                   bigint,
+  login_id                  varchar(255),
+  email                     varchar(255),
+  name                      varchar(255),
+  active                    tinyint(1) default 0,
+  email_validated           tinyint(1) default 0,
+  constraint pk_users primary key (id),
+  CONSTRAINT fk_user_credential_user FOREIGN KEY (user_id) REFERENCES n4user (id) on DELETE CASCADE)
+  row_format=compressed, key_block_size=8
+;
+
+create index ix_user_credential_user_id_1 on user_credential (user_id);
+
+alter table linked_account add constraint fk_linked_account_user_1 foreign key (user_credential_id) references user_credential (id) on delete CASCADE;
+
+create index ix_linked_account_user_credential_1 on linked_account (user_credential_id);
+
+
+# --- !Downs
+
+SET FOREIGN_KEY_CHECKS=0;
+
+drop table linked_account;
+
+drop table user_credential;
+
+SET FOREIGN_KEY_CHECKS=1;
conf/play.plugins
--- conf/play.plugins
+++ conf/play.plugins
@@ -1,1 +1,4 @@
+10005:service.YonaUserServicePlugin
+10010:com.feth.play.module.pa.providers.oauth2.google.GoogleAuthProvider
+10020:com.feth.play.module.pa.providers.oauth2.github.GithubAuthProvider
 15000:info.schleichardt.play2.mailplugin.MailPlugin
conf/routes
--- conf/routes
+++ conf/routes
@@ -10,6 +10,12 @@
 # Home page
 GET            /                                                                      controllers.Application.index()
 
+# Play!Authenticate
+GET     /logout                            controllers.Application.oAuthLogout
+GET     /authenticate/:provider            controllers.Application.oAuth(provider: String)
+GET     /authenticate/:provider/denied     controllers.Application.oAuthDenied(provider: String)
+GET     /restricted                        controllers.Restricted.index
+
 # Migration support page
 GET            /migration                                                             controllers.MigrationApp.migration()
 GET            /migration/projects                                                    controllers.MigrationApp.projects()
 
conf/social-login.conf (added)
+++ conf/social-login.conf
@@ -0,0 +1,67 @@
+#####################################################################################
+#
+# play-authenticate settings
+#
+#####################################################################################
+
+play-authenticate {
+
+    # If set to true, account merging is enabled, if set to false its disabled and accounts will never prompted to be merged
+    # defaults to true
+    accountMergeEnabled=false
+
+    # if this is set to true, accounts are automatically linked
+    # (e.g. if a user is logged in and uses a different authentication provider
+    # which has NOT yet been registered to another user, this newly used authentication
+    # provider gets added to the current local user
+    # Handle setting this to true with care
+    # If set to false, your resolver must not return null for askLink()
+    # defaults to false
+    accountAutoLink=true
+
+    # Settings for the google-based authentication provider
+    # if you are not using it, you can remove this portion of the config file
+    # and remove the Google provider from conf/play.plugins
+    google {
+        redirectUri {
+            # Whether the redirect URI scheme should be HTTP or HTTPS (HTTP by default)
+            secure=false
+
+            # You can use this setting to override the automatic detection
+            # of the host used for the redirect URI (helpful if your service is running behind a CDN for example)
+            # host=yourdomain.com
+        }
+
+        # Google credentials
+        # These are mandatory for using OAuth and need to be provided by you,
+        # if you want to use Google as an authentication provider.
+        # Get them here: https://code.google.com/apis/console
+        # Remove leading '#' after entering
+
+        # Following key values are used for test. You must set it your own values.
+        clientId=300340907286-8gr74ghhenrqgk2ioavjip36qm2bbvn1.apps.googleusercontent.com
+        clientSecret=ocFoKh7De6nDQm1x-lGxcGRO
+    }
+
+    github {
+        redirectUri {
+            # Whether the redirect URI scheme should be HTTP or HTTPS (HTTP by default)
+            secure=false
+
+            # You can use this setting to override the automatic detection
+            # of the host used for the redirect URI (helpful if your service is running behind a CDN for example)
+            # host=yourdomain.com
+        }
+
+        # Following three keys are used for Yona to Github Enterprise login support only
+        # If you just need to use Github.com login, don't uncomment it.
+        #
+        # authorizationUrl="https://your-github-enterprise/login/oauth/authorize"
+        # accessTokenUrl="https://your-github-enterprise/login/oauth/access_token"
+        # userInfoUrl="https://your-github-enterprise/api/v3/user"
+
+        # Following key values are used for test. You must set it your own values.
+        clientId=add60851e36488138581
+        clientSecret=3f9472dcd7cb4c3c09e06b03f97f1b5fe2315af3
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
conf/social-login.conf.default (added)
+++ conf/social-login.conf.default
@@ -0,0 +1,67 @@
+#####################################################################################
+#
+# play-authenticate settings
+#
+#####################################################################################
+
+play-authenticate {
+
+    # If set to true, account merging is enabled, if set to false its disabled and accounts will never prompted to be merged
+    # defaults to true
+    accountMergeEnabled=false
+
+    # if this is set to true, accounts are automatically linked
+    # (e.g. if a user is logged in and uses a different authentication provider
+    # which has NOT yet been registered to another user, this newly used authentication
+    # provider gets added to the current local user
+    # Handle setting this to true with care
+    # If set to false, your resolver must not return null for askLink()
+    # defaults to false
+    accountAutoLink=true
+
+    # Settings for the google-based authentication provider
+    # if you are not using it, you can remove this portion of the config file
+    # and remove the Google provider from conf/play.plugins
+    google {
+        redirectUri {
+            # Whether the redirect URI scheme should be HTTP or HTTPS (HTTP by default)
+            secure=false
+
+            # You can use this setting to override the automatic detection
+            # of the host used for the redirect URI (helpful if your service is running behind a CDN for example)
+            # host=yourdomain.com
+        }
+
+        # Google credentials
+        # These are mandatory for using OAuth and need to be provided by you,
+        # if you want to use Google as an authentication provider.
+        # Get them here: https://code.google.com/apis/console
+        # Remove leading '#' after entering
+
+        # Following key values are used for test. You must set it your own values.
+        clientId=300340907286-8gr74ghhenrqgk2ioavjip36qm2bbvn1.apps.googleusercontent.com
+        clientSecret=ocFoKh7De6nDQm1x-lGxcGRO
+    }
+
+    github {
+        redirectUri {
+            # Whether the redirect URI scheme should be HTTP or HTTPS (HTTP by default)
+            secure=false
+
+            # You can use this setting to override the automatic detection
+            # of the host used for the redirect URI (helpful if your service is running behind a CDN for example)
+            # host=yourdomain.com
+        }
+
+        # Following three keys are used for Yona to Github Enterprise login support only
+        # If you just need to use Github.com login, don't uncomment it.
+        #
+        # authorizationUrl="https://your-github-enterprise/login/oauth/authorize"
+        # accessTokenUrl="https://your-github-enterprise/login/oauth/access_token"
+        # userInfoUrl="https://your-github-enterprise/api/v3/user"
+
+        # Following key values are used for test. You must set it your own values.
+        clientId=add60851e36488138581
+        clientSecret=3f9472dcd7cb4c3c09e06b03f97f1b5fe2315af3
+    }
+}(파일 끝에 줄바꿈 문자 없음)
 
public/images/provider-logo/btn_google_light_normal_ios.svg (added)
+++ public/images/provider-logo/btn_google_light_normal_ios.svg
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg width="30" height="30" viewBox="0 0 30 30" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns">
+    <!-- Generator: Sketch 3.3.3 (12081) - http://www.bohemiancoding.com/sketch -->
+    <title>btn_google_light_normal_ios</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Google-Button" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" sketch:type="MSPage">
+        <g id="btn_google_light_normal" sketch:type="MSArtboardGroup" transform="translate(-1.000000, -1.000000)">
+            <g id="logo_googleg_48dp" sketch:type="MSLayerGroup" transform="translate(7.000000, 7.000000)">
+                <path d="M17.64,9.20454545 C17.64,8.56636364 17.5827273,7.95272727 17.4763636,7.36363636 L9,7.36363636 L9,10.845 L13.8436364,10.845 C13.635,11.97 13.0009091,12.9231818 12.0477273,13.5613636 L12.0477273,15.8195455 L14.9563636,15.8195455 C16.6581818,14.2527273 17.64,11.9454545 17.64,9.20454545 L17.64,9.20454545 Z" id="Shape" fill="#4285F4" sketch:type="MSShapeGroup"></path>
+                <path d="M9,18 C11.43,18 13.4672727,17.1940909 14.9563636,15.8195455 L12.0477273,13.5613636 C11.2418182,14.1013636 10.2109091,14.4204545 9,14.4204545 C6.65590909,14.4204545 4.67181818,12.8372727 3.96409091,10.71 L0.957272727,10.71 L0.957272727,13.0418182 C2.43818182,15.9831818 5.48181818,18 9,18 L9,18 Z" id="Shape" fill="#34A853" sketch:type="MSShapeGroup"></path>
+                <path d="M3.96409091,10.71 C3.78409091,10.17 3.68181818,9.59318182 3.68181818,9 C3.68181818,8.40681818 3.78409091,7.83 3.96409091,7.29 L3.96409091,4.95818182 L0.957272727,4.95818182 C0.347727273,6.17318182 0,7.54772727 0,9 C0,10.4522727 0.347727273,11.8268182 0.957272727,13.0418182 L3.96409091,10.71 L3.96409091,10.71 Z" id="Shape" fill="#FBBC05" sketch:type="MSShapeGroup"></path>
+                <path d="M9,3.57954545 C10.3213636,3.57954545 11.5077273,4.03363636 12.4404545,4.92545455 L15.0218182,2.34409091 C13.4631818,0.891818182 11.4259091,0 9,0 C5.48181818,0 2.43818182,2.01681818 0.957272727,4.95818182 L3.96409091,7.29 C4.67181818,5.16272727 6.65590909,3.57954545 9,3.57954545 L9,3.57954545 Z" id="Shape" fill="#EA4335" sketch:type="MSShapeGroup"></path>
+                <path d="M0,0 L18,0 L18,18 L0,18 L0,0 Z" id="Shape" sketch:type="MSShapeGroup"></path>
+            </g>
+        </g>
+    </g>
+</svg>(파일 끝에 줄바꿈 문자 없음)
 
public/images/provider-logo/g-logo.png (Binary) (added)
+++ public/images/provider-logo/g-logo.png
Binary file is not shown
 
public/images/provider-logo/github.svg (added)
+++ public/images/provider-logo/github.svg
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg aria-hidden="true" height="44" viewBox="0 0 46 46" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="46">
+    <title>btn_github</title>
+    <desc>Modified by Suwon Chae</desc>
+    <path
+        d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59 0.4 0.07 0.55-0.17 0.55-0.38 0-0.19-0.01-0.82-0.01-1.49-2.01 0.37-2.53-0.49-2.69-0.94-0.09-0.23-0.48-0.94-0.82-1.13-0.28-0.15-0.68-0.52-0.01-0.53 0.63-0.01 1.08 0.58 1.23 0.82 0.72 1.21 1.87 0.87 2.33 0.66 0.07-0.52 0.28-0.87 0.51-1.07-1.78-0.2-3.64-0.89-3.64-3.95 0-0.87 0.31-1.59 0.82-2.15-0.08-0.2-0.36-1.02 0.08-2.12 0 0 0.67-0.21 2.2 0.82 0.64-0.18 1.32-0.27 2-0.27 0.68 0 1.36 0.09 2 0.27 1.53-1.04 2.2-0.82 2.2-0.82 0.44 1.1 0.16 1.92 0.08 2.12 0.51 0.56 0.82 1.27 0.82 2.15 0 3.07-1.87 3.75-3.65 3.95 0.29 0.25 0.54 0.73 0.54 1.48 0 1.07-0.01 1.93-0.01 2.2 0 0.21 0.15 0.46 0.55 0.38C13.71 14.53 16 11.53 16 8 16 3.58 12.42 0 8 0z"></path></svg>(파일 끝에 줄바꿈 문자 없음)
 
public/images/yona-logo.png (Binary) (added)
+++ public/images/yona-logo.png
Binary file is not shown
Add a comment
List