1. เนื่องจากโปรเจ็คผมเป็น maven ให้ เพิ่ม dependencies ต่อไปนี้ลงไปใน pom.xml
<!-- spring --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-web</artifactId> <version>3.1.3.RELEASE</version> <type>jar</type> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context-support</artifactId> <version>3.1.3.RELEASE</version> <type>jar</type> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-beans</artifactId> <version>3.1.3.RELEASE</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-expression</artifactId> <version>3.1.3.RELEASE</version> </dependency> <!-- Spring --> <!-- spring security --> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-core</artifactId> <version>3.1.2.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-config</artifactId> <version>3.1.2.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-acl</artifactId> <version>3.1.2.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-web</artifactId> <version>3.1.2.RELEASE</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-taglibs</artifactId> <version>3.1.2.RELEASE</version> </dependency> <!-- spring security -->2. เพิ่ม file applicationContext-security.xml ไว้ใน WEB-INF
<?xml version="1.0" encoding="UTF-8"?> <beans:beans xmlns="http://www.springframework.org/schema/security" xmlns:beans="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:util="http://www.springframework.org/schema/util" xmlns:context="http://www.springframework.org/schema/context" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-3.0.xsd http://www.springframework.org/schema/security http://www.springframework.org/schema/security/spring-security-3.1.xsd http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util-3.0.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-3.0.xsd"> <global-method-security pre-post-annotations="enabled"> </global-method-security> <http use-expressions="true" auto-config="true"> <intercept-url pattern="/*" access="isAuthenticated()" /> <intercept-url pattern="/editor/*" access="hasRole('EDITOR')" /> <intercept-url pattern="/editor/admin/*" access="hasRole('ADMIN')" /> <logout logout-success-url="/login"/> <form-login login-page="/login/" default-target-url="/" always-use-default-target="true" /> <remember-me key="rememberMeKey" user-service-ref="userDetailsService"/> </http> <beans:bean id="userDetailsService" class="com.blogspot.na5cent.jsflearning.services.authentication.UserDetailServices"> </beans:bean> <authentication-manager alias="authenManager"> <authentication-provider user-service-ref="userDetailsService"> </authentication-provider> </authentication-manager> <beans:bean id="messageSource" class="org.springframework.context.support.ResourceBundleMessageSource"> <beans:property name="basename" value="springsecurity"/> </beans:bean> </beans:beans>
อธิบาย code
- <global-method-security/> เป็นการ enable annotation @Secured คือให้สามารถใช้ annotation นี้ในการจำกัดสิทธิ์การเข้าถึง method ต่างๆ ของ class ได้
- <intercept-url/> เป็น tag ที่กำหนดสิทธิ์การเข้าถึง ว่าแต่ละ url จะให้เข้าถึงได้ด้วยสิทธิ์ใด
- pattern คือ pattern ของ url นั้น เช่น /editor/admin/* คือ url ที่ขึ้นต้นด้วย /editor/admin/
- access คือ การเข้าถึง url นั้น ด้วยสิทธิ์ใด
- isAuthenticated() คือ สิทธิ์ใดก็ได้ แต่ต้อง login เข้ามาก่อน
- hasRole(‘ADMIN’) คือสิทธิ์การเข้าใช้งาน url นั้นเป็น ADMIN
- hasRole(‘EDITOR’) คือสิทธิ์การเข้าใช้งาน url นั้นเป็น EDITOR
- <logout/> คอนฟิก เกี่ยวกับการ logout
- logout-success-utl คือ เมื่อ logout success แล้วให้ไปที่ page ใด
- <form-login/> คอนฟิกเกี่ยวกับ form login
- login-page คือ page login อยู่ที่ page ใด ในที่นี้คือ “/login/”
- default-target-url คือ เมื่อ login เสร็จ จะให้ไปที่ page ใด ในที่นี้คือ “/” (root context)
- always-use-default-target คือ ใช้ url จาก default-target-url เสมอ
- <remember-me/> ใช้สำหรับการจำรหัสผ่านของ web application
- key
- user-service-ref คือ service ที่ใช้ในการตรวจสอบการจำการ login (ใช้ตัวเดียวกันกับตัว login)
- <beans:bean /> เป็นการอ้างไปถึง bean นั้นๆ
- id=”userDetailsService” คือเป็นการ identify bean นั้นๆ
- class=”com.blogspot….” ระบุว่า userDetailsService นั้น เก็บไว้ที่ใด หรือใช้ Class ใดเป็น service login
- <authentication-manager/> เป็นส่วน config service login
- <authentication-provider/> เป็นการกำหนด login service
- user-service-ref คือ ให้อ้างไปที่ service ใด (ใช้ service ใดในการ login)
- <beans:bean/> เป็นการอ้างไปถึง bean นั้นๆ
- id=”messageSource” คือเป็นการ identify bean นั้นๆ (ในที่นี้คือการ config พวก error message โดยเราจะให้ class ResourceBundleMessageSource เป็นตัวจัดการ error message ที่เกิดจากการ login เช่น login ไม่ผ่าน username password ไม่ถูกต้อง, user ถูก disabled ไว้เป็นต้น)
- class=”org.springframwork…”
3. สร้าง service สำหรับ authentication provider หรือ service ที่ใช้สำหรับการ login (authentication)
ในที่นี้เราจะ implements UserDetailsService ซึ่งจะมี method
loadUserByUsername ที่เราต้อง implement มันเอง spring จะส่ง username
มาให้ จากนั้นเราอาจจะใช้ username
นั้นไป find user ใน database (ใช้แค่ username) แล้ว return เป็น oject UserDetails หลังจากนั้น
spring จะไปตรวจสอบ password ให้เราเอง เพื่อป้องกันการโจมตีจาก SQL injection
package com.blogspot.na5cent.jsflearning.services.authentication; import com.blogspot.na5cent.jsflearning.model.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; /** * * @author Redcrow */ public class UserDetailServices implements UserDetailsService { @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { User user = new User(); System.out.println("try login by user name => " + username); if (!"admin".equals(username) && !"editor".equals(username) && !"public".equals(username)) { throw new UsernameNotFoundException("login incorrect"); }else{ user.setUsername(username); user.setPassword("1234"); } return new UserDetaislApp(user); } }4. สร้าง class user details (สำหรับการตรวจสอบสิทธิ์ หรือ authorization)
package com.blogspot.na5cent.jsflearning.services.authentication; import com.blogspot.na5cent.jsflearning.model.User; import java.util.Collection; import java.util.HashSet; import java.util.Set; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; /** * * @author Redcrow */ public class UserDetaislApp implements UserDetails { public static final GrantedAuthority ADMIN_ROLE = new SimpleGrantedAuthority("ADMIN"); public static final GrantedAuthority EDITOR_ROLE = new SimpleGrantedAuthority("EDITOR"); public static final GrantedAuthority PUBLIC_ROLE = new SimpleGrantedAuthority("PUBLIC"); private User user; private Set<GrantedAuthority> grants; public UserDetaislApp(User user) { this.user = user; } @Override public Collection<? extends GrantedAuthority> getAuthorities() { if (grants == null) { grants = new HashSet<GrantedAuthority>(); switch (user.getUsername()) { case "admin": grants.add(PUBLIC_ROLE); grants.add(EDITOR_ROLE); grants.add(ADMIN_ROLE); break; case "editor": grants.add(PUBLIC_ROLE); grants.add(EDITOR_ROLE); break; default: grants.add(PUBLIC_ROLE); break; } } return grants; } @Override public String getPassword() { return user.getPassword(); } @Override public String getUsername() { return user.getUsername(); } @Override public boolean isAccountNonExpired() { return true; } @Override public boolean isAccountNonLocked() { return true; } @Override public boolean isCredentialsNonExpired() { return true; } @Override public boolean isEnabled() { return true; } }
- บรรทัดที่ 17- 19 GrantedAuthority คือ class ที่ใช้สำหรับระบุสิทธิ์ การเข้าถึง ซึ่งในที่นี้เราจะระบุกี่สิทธิ์ก็ได้
- บรรทัดที่ 29 method getAuthorities() เป็น method ที่ใช้สำหรับกำหนดสิทธิ์ ว่าเราจะให้ user ใด มีสิทธิ์ใดได้บ้าง ซึ่ง spring framework จะมาอ่านไปเอง
5. ที่ web.xml ให้เพิ่ม config ต่อไปนี้ลงไป
... ... ... <context-param> <param-name>contextConfigLocation</param-name> <param-value>/WEB-INF/applicationContext*.xml</param-value> </context-param> <listener> <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class> </listener> <!-- security --> <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <listener> <listener-class>org.springframework.security.web.session.HttpSessionEventPublisher</listener-class> </listener> <!-- security --> ... ... ...
- บรรทัดที่ 4-7 <context-param/> เป็นการกำหนดตัวแปร ให้ contextConfigLocation อ้างไปที่ /WEB-INF/applicationContext*.xml หมายถึง config ของ spring จะเป็น file ที่ขึ้นต้นด้วย applicationContext ซึ่ง spring framework จะมาอ่านค่าตัวแปรนี้ตอน start application เพื่อนำ file config ต่างๆ ไปดำเนินการ
- บรรทัดที่ 9-11 <listener/> เป็นการสร้าง listener เพื่อคอยดักฟังเหตุการณ์บางอย่างที่อาจจะเกิดขึ้น ซึ่งในที่นี้เป็น servlet context list ที่เอาไว้ listener ตอน application start และ application destroy เพื่อให้ spring ทำการกำหนดค่า และเคลียร์ค่าข้อมูลบางอย่าง
- บรรทัดที่ 14-21<filter/> เป็น filter ที่เอาไว้กรอง request response จาก client เพื่อตรวจสอบการ login และสิทธิ์การเข้าถึงในแต่ละ request <filter-mapping/> ให้ filter ที่ url ใดบ้าง ซึ่งในที่นี้คือ ทุกๆ url
6. เขียน html file หน้า login
<?xml version='1.0' encoding='UTF-8' ?> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:h="http://java.sun.com/jsf/html" xmlns:p="http://primefaces.org/ui" xml:lang="en" lang="en"> <h:head> <title>login page</title> </h:head> <h:body> <form name='f' action='#{facesContext.externalContext.requestContextPath}/j_spring_security_check' method='POST'> <table> <tr> <td> username : </td> <td> <input name="j_username"/> </td> </tr> <tr> <td> password : </td> <td> <input name="j_password" type="password"/> </td> </tr> <tr> <td> remember me : </td> <td> <input type='checkbox' name='_spring_security_remember_me'/> </td> </tr> <tr> <td> </td> <td> <button type="sybmit">login</button> </td> </tr> </table> </form> </h:body> </html>
- บรรทัดที่ 12 <form/> เป็น form สำหรับการ login โดยมี method เป็น POST และให้ action ไปที่ “/context root/j_spring_security_check”
public page : contextRoot/index.xhtml
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>public</title> </head> <body> <b>public page</b><br/> <a href="editor/">Editor</a><br/> <a href="editor/admin/">Admin</a> <br/> <br/> <a href="#{facesContext.externalContext.requestContextPath}/j_spring_security_logout">logout</a> </body> </html>การ logout เราจะให้ link ชี้ไปที่ j_spring_security_logout
User admin สามารถเข้าใช้งานได้ทุกหน้า เพราะว่าถ้า user name == “admin” ให้มี authority ทุก role
ทดสอบเข้าใช้งานด้วย username :
editor
สังเกตว่า user
editor ไม่สามารถเข้าใช้งาน page ที่เป็น /editor/admin/
ได้ เพราะ page นี้อนุณาตเฉพาะสิทธิ์ admin เท่านั้น
ทดสอบเข้าใช้งานด้วย username : public
โค๊ดสำหรับไม่ให้ browser cache page html ไว้ ใช้ในกรณีที่เรา logout แล้ว มันสามารถ back กลับมายังหน้าเดิมได้
CacheControlFilter.java
package com.blogspot.na5cent.jsflearning.filters; import java.io.IOException; import java.util.Date; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletResponse; /** * * @author Redcrow */ @WebFilter(urlPatterns="/*", filterName="cacheControlFilter") public class CacheControlFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse resp = (HttpServletResponse) response; resp.setHeader("Expires", new Date().toString()); resp.setHeader("Last-Modified", new Date().toString()); resp.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0, post-check=0, pre-check=0"); resp.setHeader("Pragma", "no-cache"); chain.doFilter(request, response); } @Override public void destroy() { } @Override public void init(FilterConfig arg0) throws ServletException { } }การดึงข้อมูล user ที่ login มาใช้งาน
Logins.java
package com.blogspot.na5cent.jsflearning.services.authentication; import org.springframework.security.core.Authentication; import com.blogspot.na5cent.jsflearning.model.User; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; /** * * @author Redcrow */ public class Logins { public static User getUser() { SecurityContext securityContext = SecurityContextHolder.getContext(); Authentication authen = securityContext.getAuthentication(); User user = null; if (authen != null) { Object principal = authen.getPrincipal(); if (pricipal instanceof UserDetailsApp) { user = ((UserDetailsApp) principal).getUser(); } else if (principal instanceof String) { user = new User(); user.setUsername("anonymous"); } } return user; } }เรียกใช้งาน
SessionMB.java
package com.blogspot.na5cent.jsflearning.managedbean; import com.blogspot.na5cent.jsflearning.model.User; import com.blogspot.na5cent.jsflearning.services.authentication.Logins; import javax.annotation.PostConstruct; import javax.faces.bean.ManagedBean; import javax.faces.bean.SessionScoped; /** * * @author Redcrow */ @ManagedBean @SessionScoped public class SessionMB { private User user; @PostConstruct public void postContruct() { user = Logins.getUser(); } public User getUser() { if(user == null){ user = new User(); } return user; } }ทดสอบ
หน้าแรกที่ login เข้ามาน่ะครับ
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <title>public</title> </head> <body> user [ #{sessionMB.user.username} : #{sessionMB.user.password} ] <br/> <b>public page</b><br/> <a href="editor/">Editor</a><br/> <a href="editor/admin/">Admin</a> <br/> <br/> <a href="#{facesContext.externalContext.requestContextPath}/j_spring_security_logout">logout</a> </body> </html>
การแสดงข้อผิดพลาดจากการ Login
ให้นำ file springsecurity.properties ซึ่งเป็น file ที่ใช้สำหรับการเก็บ message error ต่างๆ ไปเก็บไว้ที่ class path
springsecurity.properties
AuthByAdapterProvider.incorrectKey=The presented AuthByAdapter implementation does not contain the expected key BasicAclEntryAfterInvocationProvider.noPermission=Authentication {0} has NO permissions at all to the domain object {1} BasicAclEntryAfterInvocationProvider.insufficientPermission=Authentication {0} has ACL permissions to the domain object, but not the required ACL permission to the domain object {1} ConcurrentSessionControllerImpl.exceededAllowed=Maximum sessions of {0} for this principal exceeded ProviderManager.providerNotFound=No AuthenticationProvider found for {0} AnonymousAuthenticationProvider.incorrectKey=The presented AnonymousAuthenticationToken does not contain the expected key CasAuthenticationProvider.incorrectKey=The presented CasAuthenticationToken does not contain the expected key CasAuthenticationProvider.noServiceTicket=Failed to provide a CAS service ticket to validate NamedCasProxyDecider.untrusted=Nearest proxy {0} is untrusted RejectProxyTickets.reject=Proxy tickets are rejected AbstractSecurityInterceptor.authenticationNotFound=An Authentication object was not found in the SecurityContext AbstractUserDetailsAuthenticationProvider.onlySupports=Only UsernamePasswordAuthenticationToken is supported AbstractUserDetailsAuthenticationProvider.locked=รหัสผู้ใช้งานนี้ถูกล็อก AbstractUserDetailsAuthenticationProvider.disabled=รหัสผู้ใช้งานนี้ถูกระงับการใช้งาน AbstractUserDetailsAuthenticationProvider.expired=รหัสผู้ใช้งานนี้หมดอายุ AbstractUserDetailsAuthenticationProvider.credentialsExpired=รหัสผู้ใช้งานนี้หมดอายุ AbstractUserDetailsAuthenticationProvider.badCredentials=รหัสผู้ใช้งาน/รหัสผ่าน ไม่ถูกต้อง X509AuthenticationProvider.certificateNull=Certificate is null DaoX509AuthoritiesPopulator.noMatching=No matching pattern was found in subjectDN: {0} RememberMeAuthenticationProvider.incorrectKey=The presented RememberMeAuthenticationToken does not contain the expected key RunAsImplAuthenticationProvider.incorrectKey=The presented RunAsUserToken does not contain the expected key DigestProcessingFilter.missingMandatory=Missing mandatory digest value; received header {0} DigestProcessingFilter.missingAuth=Missing mandatory digest value for 'auth' QOP; received header {0} DigestProcessingFilter.incorrectRealm=Response realm name {0} does not match system realm name of {1} DigestProcessingFilter.nonceExpired=Nonce has expired/timed out DigestProcessingFilter.nonceEncoding=Nonce is not encoded in Base64; received nonce {0} DigestProcessingFilter.nonceNotTwoTokens=Nonce should have yielded two tokens but was {0} DigestProcessingFilter.nonceNotNumeric=Nonce token should have yielded a numeric first token, but was {0} DigestProcessingFilter.nonceCompromised=Nonce token compromised {0} DigestProcessingFilter.usernameNotFound=Username {0} not found DigestProcessingFilter.incorrectResponse=Incorrect response JdbcDaoImpl.notFound=User {0} not found JdbcDaoImpl.noAuthority=User {0} has no GrantedAuthority SwitchUserProcessingFilter.noCurrentUser=No current user associated with this request SwitchUserProcessingFilter.noOriginalAuthentication=Could not find original Authentication object SwitchUserProcessingFilter.usernameNotFound=Username {0} not found SwitchUserProcessingFilter.locked=รหัสผู้ใช้งานนี้ถูกล็อก SwitchUserProcessingFilter.disabled=รหัสผู้ใช้งานนี้ถูกระงับการใช้งาน SwitchUserProcessingFilter.expired=รหัสผู้ใช้งานนี้หมดอายุ SwitchUserProcessingFilter.credentialsExpired=รหัสผู้ใช้งานนี้หมดอายุ AbstractAccessDecisionManager.accessDenied=Access is denied LdapAuthenticationProvider.emptyUsername=Empty username not allowed LdapAuthenticationProvider.emptyPassword=รหัสผู้ใช้งาน/รหัสผ่าน ไม่ถูกต้อง DefaultIntitalDirContextFactory.communicationFailure=Unable to connect to LDAP server DefaultIntitalDirContextFactory.badCredentials=รหัสผู้ใช้งาน/รหัสผ่าน ไม่ถูกต้อง DefaultIntitalDirContextFactory.unexpectedException=Failed to obtain InitialDirContext due to unexpected exception PasswordComparisonAuthenticator.badCredentials=รหัสผู้ใช้งาน/รหัสผ่าน ไม่ถูกต้อง BindAuthenticator.badCredentials=รหัสผู้ใช้งาน/รหัสผ่าน ไม่ถูกต้อง BindAuthenticator.failedToLoadAttributes=รหัสผู้ใช้งาน/รหัสผ่าน ไม่ถูกต้อง UserDetailsService.locked=รหัสผู้ใช้งานนี้ถูกล็อก UserDetailsService.disabled=รหัสผู้ใช้งานนี้ถูกระงับการใช้งาน UserDetailsService.expired=รหัสผู้ใช้งานนี้หมดอายุ UserDetailsService.credentialsExpired=รหัสผู้ใช้งานนี้หมดอายุที่หน้า login ให้เพิ่มต่อไปนี้ลงไป
... ... ${sessionScope["SPRING_SECURITY_LAST_EXCEPTION"].message} ... ..เวลาที่ login ไม่ได้ มันก็จะขึ้นแบบนี้
ใช้ isUserInRole ในการตรวจสอบสิทธิ์
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" xmlns:h="http://java.sun.com/jsf/html" xmlns:p="http://primefaces.org/ui" > <h:head> <title>public</title> </h:head> <h:body> user [ #{sessionMB.user.username} : #{sessionMB.user.password} ] <br/> <b>public page</b><br/> <a href="editor/">Editor</a><br/> <a href="editor/admin/">Admin</a> <br/> <br/> <p:commandButton value="admin" type="button" rendered="#{facesContext.externalContext.isUserInRole('ADMIN')}"/> <p:spacer width="5"/> <p:commandButton value="editor" type="button" rendered="#{facesContext.externalContext.isUserInRole('EDITOR')}"/> <p:spacer width="5"/> <p:commandButton value="public" type="button" rendered="#{facesContext.externalContext.isUserInRole('PUBLIC')}"/> <br/> <br/> <a href="#{facesContext.externalContext.requestContextPath}/j_spring_security_logout">logout</a> </h:body> </html>
ทดสอบเข้าใช้งานด้วย user public
ทดสอบเข้าใช้งานด้วย user editor
ทดสอบเข้าใช้งานด้วย user admin
การตรวจสอบว่า user login รึยัง เราจะใช้
<h:outputLink value="#{facesContext.externalContext.requestContextPath}/login.xhtml" rendered="#{empty facesContext.externalContext.userPrincipal.name}"> <h:outputText value="เข้าสู่ระบบ" styleClass="blue-text"/> </h:outputLink> <h:outputLink value="#{facesContext.externalContext.requestContextPath}/login.xhtml" rendered="#{not empty facesContext.externalContext.userPrincipal.name}"> <h:outputText value="ออกจากระบบ" styleClass="blue-text"/> </h:outputLink>
ถ้า user login แล้ว facesContext.externalContext.userPrincipal.name จะไม่เป็นค่าว่าง
แต่ถ้ายังไม่ได้ login facesContext.externalContext.userPrincipal.name จะเป็นค่าว่างครับ
หวังว่าบทความที่ผมเขียนจะพอเป็นประโยชน์สำหรับคนอื่นได้บ้างน่ะครับ ^____^
ขอบคุณมากครับ สำหรับบทความนี้ครับ ^_^
ตอบลบครับ ยินดีครับ
ตอบลบขอบคุณครับ สำหรับความรู้
ตอบลบผมขอไฟร์มาเป็นตัวอย่างหน่อยนะครับ
ลองดูใน project นี้น่ะครับ https://github.com/jittagornp/income
ลบสุดยอดครับ อยากสอบถามติดต่อทางไหนครับ
ตอบลบ